Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions internal/api/v1beta1connect/permission_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@ func (h *ConnectHandler) CheckFederatedResourcePermission(ctx context.Context, r

objectNamespace, objectID, err := schema.SplitNamespaceAndResourceID(req.Msg.GetResource())
if err != nil || objectNamespace == "" || objectID == "" {
return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest)
return nil, connect.NewError(connect.CodeInvalidArgument, ErrNamespaceSplitNotation)
}

principalNamespace, principalID, err := schema.SplitNamespaceAndResourceID(req.Msg.GetSubject())
if err != nil || principalNamespace == "" || principalID == "" {
return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest)
return nil, connect.NewError(connect.CodeInvalidArgument, ErrNamespaceSplitNotation)
}

permissionName, err := h.getPermissionName(ctx, objectNamespace, req.Msg.GetPermission())
Expand Down Expand Up @@ -162,13 +162,8 @@ func (h *ConnectHandler) CheckResourcePermission(ctx context.Context, req *conne
errorLogger := NewErrorLogger()

objectNamespace, objectID, err := schema.SplitNamespaceAndResourceID(req.Msg.GetResource())
//nolint:staticcheck
if len(req.Msg.GetResource()) == 0 || err != nil {
objectNamespace = schema.ParseNamespaceAliasIfRequired(req.Msg.GetObjectNamespace())
objectID = req.Msg.GetObjectId()
}
if objectNamespace == "" || objectID == "" {
return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest)
if err != nil || objectNamespace == "" || objectID == "" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This drops the fallback to object_id / object_namespace, so a client sending only those (with no resource) now gets InvalidArgument. That is the intended tightening, but those fields are still in the .proto, so the schema still advertises support the server no longer provides. A clear deprecation note in the proto, or a planned removal, would keep integrations from being surprised at runtime.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proto will be cleaned up later

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding to that: the fields already carry deprecated = true in the proto, so the schema does flag it — the generated getters are marked deprecated too. The removal itself rides a later proton sync.

return nil, connect.NewError(connect.CodeInvalidArgument, ErrNamespaceSplitNotation)
}

permissionName, err := h.getPermissionName(ctx, objectNamespace, req.Msg.GetPermission())
Expand Down Expand Up @@ -201,8 +196,8 @@ func (h *ConnectHandler) BatchCheckPermission(ctx context.Context, req *connect.
checks := make([]resource.Check, 0, len(req.Msg.GetBodies()))
for _, body := range req.Msg.GetBodies() {
objectNamespace, objectID, err := schema.SplitNamespaceAndResourceID(body.GetResource())
if len(body.GetResource()) == 0 || err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest)
if err != nil || objectNamespace == "" || objectID == "" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One malformed or empty-part resource in any body now makes the whole batch return InvalidArgument with zero results. For a batch endpoint that is surprising: 49 valid checks are dropped because of 1 bad body. Consider failing just that item, or returning a per-item error, rather than the whole call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping whole-batch rejection in this PR: that's the pre-existing contract for a malformed body here — before this change a malformed resource already failed the whole call, and an empty-part one reached SpiceDB and failed the whole call as internal. This PR only turns that into a clean InvalidArgument. Failing just the bad item needs a response-shape change (BatchCheckPermissionResponsePair has no per-item error field), so it's a proto addition rather than a handler tweak. Happy to take that as a follow-up if we want the per-item contract.

return nil, connect.NewError(connect.CodeInvalidArgument, ErrNamespaceSplitNotation)
}

permissionName, err := h.getPermissionName(ctx, objectNamespace, body.GetPermission())
Expand Down
122 changes: 114 additions & 8 deletions internal/api/v1beta1connect/permission_check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,48 @@ func TestHandler_CheckResourcePermission(t *testing.T) {
wantErr error
}{
{
name: "should return bad request error if object id is empty or namespace is empty",
name: "should return bad request error if resource is malformed",
request: connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
Resource: "not-namespace-uuid-format",
}),
want: nil,
wantErr: connect.NewError(connect.CodeInvalidArgument, ErrBadRequest),
wantErr: connect.NewError(connect.CodeInvalidArgument, ErrNamespaceSplitNotation),
},
{
name: "should return bad request error if resource is missing",
request: connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
Permission: schema.UpdatePermission,
}),
want: nil,
wantErr: connect.NewError(connect.CodeInvalidArgument, ErrNamespaceSplitNotation),
},
{
name: "should return bad request error if resource id part is empty",
request: connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
Resource: "organization:",
Permission: schema.UpdatePermission,
}),
want: nil,
wantErr: connect.NewError(connect.CodeInvalidArgument, ErrNamespaceSplitNotation),
},
{
name: "should return bad request error if resource namespace part is empty",
request: connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
Resource: ":" + testRelationV2.Object.ID,
Permission: schema.UpdatePermission,
}),
want: nil,
wantErr: connect.NewError(connect.CodeInvalidArgument, ErrNamespaceSplitNotation),
},
{
name: "should return bad request error if only the removed split fields are sent",
request: connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
ObjectId: testRelationV2.Object.ID,
ObjectNamespace: testRelationV2.Object.Namespace,
Permission: schema.UpdatePermission,
}),
want: nil,
wantErr: connect.NewError(connect.CodeInvalidArgument, ErrNamespaceSplitNotation),
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
name: "should return user unauthenticated error if CheckAuthz function returns ErrUnauthenticated",
Expand Down Expand Up @@ -91,9 +127,8 @@ func TestHandler_CheckResourcePermission(t *testing.T) {
Return(testPermission, nil)
},
request: connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
ObjectId: testRelationV2.Object.ID,
ObjectNamespace: testRelationV2.Object.Namespace,
Permission: schema.UpdatePermission,
Permission: schema.UpdatePermission,
Resource: schema.JoinNamespaceAndResourceID(testRelationV2.Object.Namespace, testRelationV2.Object.ID),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The success cases use canonical namespaces via JoinNamespaceAndResourceID, and none pass a real alias in the resource field. The PR says aliases still work there, so a case with resource set to an alias (e.g. "org:") would lock that promise in and catch any regression in ParseNamespaceAliasIfRequired for this path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in f00e7d3 — a unit case now sends org:<id> and asserts the check runs against app/organization.

}),
want: connect.NewResponse(&frontierv1beta1.CheckResourcePermissionResponse{
Status: true,
Expand All @@ -113,15 +148,35 @@ func TestHandler_CheckResourcePermission(t *testing.T) {
Return(testPermission, nil)
},
request: connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
ObjectId: testRelationV2.Object.ID,
ObjectNamespace: testRelationV2.Object.Namespace,
Permission: schema.UpdatePermission,
Permission: schema.UpdatePermission,
Resource: schema.JoinNamespaceAndResourceID(testRelationV2.Object.Namespace, testRelationV2.Object.ID),
}),
want: connect.NewResponse(&frontierv1beta1.CheckResourcePermissionResponse{
Status: false,
}),
wantErr: nil,
},
{
name: "should resolve a namespace alias in the resource field",
setup: func(res *mocks.ResourceService, perm *mocks.PermissionService) {
res.EXPECT().CheckAuthz(mock.AnythingOfType("context.backgroundCtx"), resource.Check{
Object: relation.Object{
ID: testRelationV2.Object.ID,
Namespace: schema.OrganizationNamespace,
}, Permission: schema.UpdatePermission,
}).Return(true, nil)
perm.EXPECT().Get(mock.Anything, schema.JoinNamespaceAndResourceID(schema.OrganizationNamespace, schema.UpdatePermission)).
Return(permission.Permission{Name: schema.UpdatePermission, NamespaceID: schema.OrganizationNamespace}, nil)
},
request: connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
Permission: schema.UpdatePermission,
Resource: "org:" + testRelationV2.Object.ID,
}),
want: connect.NewResponse(&frontierv1beta1.CheckResourcePermissionResponse{
Status: true,
}),
wantErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -138,3 +193,54 @@ func TestHandler_CheckResourcePermission(t *testing.T) {
})
}
}

func TestHandler_BatchCheckPermission(t *testing.T) {
tests := []struct {
name string
setup func(res *mocks.ResourceService, perm *mocks.PermissionService)
request *connect.Request[frontierv1beta1.BatchCheckPermissionRequest]
wantErr error
}{
{
name: "should return bad request error if a body resource is malformed",
request: connect.NewRequest(&frontierv1beta1.BatchCheckPermissionRequest{
Bodies: []*frontierv1beta1.BatchCheckPermissionBody{
{Resource: "not-namespace-uuid-format", Permission: schema.UpdatePermission},
},
}),
wantErr: connect.NewError(connect.CodeInvalidArgument, ErrNamespaceSplitNotation),
},
{
name: "should return bad request error if a body resource id part is empty",
request: connect.NewRequest(&frontierv1beta1.BatchCheckPermissionRequest{
Bodies: []*frontierv1beta1.BatchCheckPermissionBody{
{Resource: "organization:", Permission: schema.UpdatePermission},
},
}),
wantErr: connect.NewError(connect.CodeInvalidArgument, ErrNamespaceSplitNotation),
},
{
name: "should return bad request error if a body resource namespace part is empty",
request: connect.NewRequest(&frontierv1beta1.BatchCheckPermissionRequest{
Bodies: []*frontierv1beta1.BatchCheckPermissionBody{
{Resource: ":" + testRelationV2.Object.ID, Permission: schema.UpdatePermission},
},
}),
wantErr: connect.NewError(connect.CodeInvalidArgument, ErrNamespaceSplitNotation),
},
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockResourceSrv := new(mocks.ResourceService)
mockPermissionSrv := new(mocks.PermissionService)
if tt.setup != nil {
tt.setup(mockResourceSrv, mockPermissionSrv)
}

mockDep := &ConnectHandler{resourceService: mockResourceSrv, permissionService: mockPermissionSrv}
resp, err := mockDep.BatchCheckPermission(context.Background(), tt.request)
assert.Equal(t, tt.wantErr, err)
assert.Nil(t, resp)
})
}
}
5 changes: 2 additions & 3 deletions test/e2e/regression/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1676,9 +1676,8 @@ func (s *APIRegressionTestSuite) TestRelationAPI() {
s.Assert().Equal(true, checkViewPermResp.Msg.GetStatus())

checkEditPermResp, err := s.testBench.Client.CheckResourcePermission(ctxOrgUserAuth, connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
ObjectId: existingOrg.Msg.GetOrganization().GetId(),
ObjectNamespace: schema.OrganizationNamespace,
Permission: schema.UpdatePermission,
Resource: schema.JoinNamespaceAndResourceID(schema.OrganizationNamespace, existingOrg.Msg.GetOrganization().GetId()),
Permission: schema.UpdatePermission,
}))
s.Assert().NoError(err)
s.Assert().Equal(true, checkEditPermResp.Msg.GetStatus())
Expand Down
30 changes: 12 additions & 18 deletions test/e2e/regression/onboarding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,8 @@ func (s *OnboardingRegressionTestSuite) TestOnboardOrganizationWithUser() {
})
s.Run("4. org admin should have access to the resource created", func() {
createResourceResp, err := s.testBench.Client.CheckResourcePermission(ctx, connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
ObjectId: resourceID,
ObjectNamespace: computeOrderNamespace,
Permission: schema.UpdatePermission,
Resource: schema.JoinNamespaceAndResourceID(computeOrderNamespace, resourceID),
Permission: schema.UpdatePermission,
}))
s.Assert().NoError(err)
s.Assert().NotNil(createResourceResp)
Expand Down Expand Up @@ -245,19 +244,17 @@ func (s *OnboardingRegressionTestSuite) TestOnboardOrganizationWithUser() {
userCtx := testbench.ContextWithAuth(context.Background(), newUserCookie)

checkUpdateProjectResp, err := s.testBench.Client.CheckResourcePermission(userCtx, connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
ObjectId: projectID,
ObjectNamespace: schema.ProjectNamespace,
Permission: schema.UpdatePermission,
Resource: schema.JoinNamespaceAndResourceID(schema.ProjectNamespace, projectID),
Permission: schema.UpdatePermission,
}))
s.Assert().NoError(err)
s.Assert().NotNil(checkUpdateProjectResp)
s.Assert().True(checkUpdateProjectResp.Msg.GetStatus())

// resources under the project
checkUpdateResourceResp, err := s.testBench.Client.CheckResourcePermission(userCtx, connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
ObjectId: resourceID,
ObjectNamespace: computeOrderNamespace,
Permission: schema.UpdatePermission,
Resource: schema.JoinNamespaceAndResourceID(computeOrderNamespace, resourceID),
Permission: schema.UpdatePermission,
}))
s.Assert().NoError(err)
s.Assert().NotNil(checkUpdateResourceResp)
Expand All @@ -269,9 +266,8 @@ func (s *OnboardingRegressionTestSuite) TestOnboardOrganizationWithUser() {
userCtx := testbench.ContextWithAuth(context.Background(), newUserCookie)

checkUpdateOrgResp, err := s.testBench.Client.CheckResourcePermission(userCtx, connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
ObjectId: orgID,
ObjectNamespace: schema.OrganizationNamespace,
Permission: schema.UpdatePermission,
Resource: schema.JoinNamespaceAndResourceID(schema.OrganizationNamespace, orgID),
Permission: schema.UpdatePermission,
}))
s.Assert().NoError(err)
s.Assert().NotNil(checkUpdateOrgResp)
Expand Down Expand Up @@ -323,18 +319,16 @@ func (s *OnboardingRegressionTestSuite) TestOnboardOrganizationWithUser() {
userCtx := testbench.ContextWithAuth(context.Background(), newUserCookie)

checkGetResourceResp, err := s.testBench.Client.CheckResourcePermission(userCtx, connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
ObjectId: resourceID,
ObjectNamespace: computeOrderNamespace,
Permission: schema.GetPermission,
Resource: schema.JoinNamespaceAndResourceID(computeOrderNamespace, resourceID),
Permission: schema.GetPermission,
}))
s.Assert().NoError(err)
s.Assert().NotNil(checkGetResourceResp)
s.Assert().True(checkGetResourceResp.Msg.GetStatus())

checkUpdateResourceResp, err := s.testBench.Client.CheckResourcePermission(userCtx, connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
ObjectId: resourceID,
ObjectNamespace: computeOrderNamespace,
Permission: schema.UpdatePermission,
Resource: schema.JoinNamespaceAndResourceID(computeOrderNamespace, resourceID),
Permission: schema.UpdatePermission,
}))
s.Assert().NoError(err)
s.Assert().NotNil(checkUpdateResourceResp)
Expand Down
10 changes: 4 additions & 6 deletions test/e2e/regression/serviceusers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,8 @@ func (s *ServiceUsersRegressionTestSuite) TestServiceUserWithKey() {
s.Assert().NoError(err)

checkPermAfterResp, err := s.testBench.Client.CheckResourcePermission(ctxWithKey, connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
ObjectId: existingOrg.Msg.GetOrganization().GetId(),
ObjectNamespace: "organization",
Permission: schema.UpdatePermission,
Resource: schema.JoinNamespaceAndResourceID("organization", existingOrg.Msg.GetOrganization().GetId()),
Permission: schema.UpdatePermission,
}))
s.Assert().NoError(err)
s.Assert().True(checkPermAfterResp.Msg.GetStatus())
Expand Down Expand Up @@ -526,9 +525,8 @@ func (s *ServiceUsersRegressionTestSuite) TestServiceUserWithSecret() {
s.Assert().NoError(err)

checkPermAfterResp, err := s.testBench.Client.CheckResourcePermission(ctxWithKey, connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{
ObjectId: existingOrg.Msg.GetOrganization().GetId(),
ObjectNamespace: "organization",
Permission: schema.ProjectCreatePermission,
Resource: schema.JoinNamespaceAndResourceID("organization", existingOrg.Msg.GetOrganization().GetId()),
Permission: schema.ProjectCreatePermission,
}))
s.Assert().NoError(err)
s.Assert().True(checkPermAfterResp.Msg.GetStatus())
Expand Down
Loading