diff --git a/app/controlplane/internal/service/apitoken.go b/app/controlplane/internal/service/apitoken.go index a0a41bb0f..bf035bb15 100644 --- a/app/controlplane/internal/service/apitoken.go +++ b/app/controlplane/internal/service/apitoken.go @@ -20,6 +20,7 @@ import ( "time" pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" errors "github.com/go-kratos/kratos/v2/errors" @@ -53,6 +54,13 @@ func (s *APITokenService) Create(ctx context.Context, req *pb.APITokenServiceCre return nil, errors.BadRequest("invalid", "project is required") } + // Org-level API tokens can only create project-scoped tokens + if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil { + if !req.ProjectReference.IsSet() { + return nil, errors.Forbidden("forbidden", "org-level API tokens must specify a project when creating new tokens") + } + } + // if the project is provided we make sure it exists and the user has permission to it var project *biz.Project if req.ProjectReference.IsSet() { @@ -100,7 +108,13 @@ func (s *APITokenService) List(ctx context.Context, req *pb.APITokenServiceListR defaultProjectFilter = []uuid.UUID{project.ID} } - tokens, err := s.APITokenUseCase.List(ctx, currentOrg.ID, biz.WithAPITokenStatusFilter(mapTokenStatusFilter(req.GetStatusFilter())), biz.WithAPITokenProjectFilter(defaultProjectFilter), biz.WithAPITokenScope(mapTokenScope(req.Scope))) + // Org-level API tokens can only see project-scoped tokens + scope := mapTokenScope(req.Scope) + if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil { + scope = biz.APITokenScopeProject + } + + tokens, err := s.APITokenUseCase.List(ctx, currentOrg.ID, biz.WithAPITokenStatusFilter(mapTokenStatusFilter(req.GetStatusFilter())), biz.WithAPITokenProjectFilter(defaultProjectFilter), biz.WithAPITokenScope(scope)) if err != nil { return nil, handleUseCaseErr(err, s.log) } @@ -151,6 +165,13 @@ func (s *APITokenService) Revoke(ctx context.Context, req *pb.APITokenServiceRev return nil, errors.BadRequest("invalid", "you can not manage a global API token") } + // Org-level API tokens cannot revoke other org-level tokens + if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil { + if t.ProjectID == nil { + return nil, errors.Forbidden("forbidden", "org-level API tokens cannot revoke org-level tokens") + } + } + // Make sure the user has permission to revoke the token in the project if t.ProjectID != nil { if err := s.authorizeResource(ctx, authz.PolicyAPITokenRevoke, authz.ResourceTypeProject, *t.ProjectID); err != nil { diff --git a/app/controlplane/internal/service/apitoken_test.go b/app/controlplane/internal/service/apitoken_test.go new file mode 100644 index 000000000..7483db50a --- /dev/null +++ b/app/controlplane/internal/service/apitoken_test.go @@ -0,0 +1,132 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package service + +import ( + "context" + "testing" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestAPITokenService_Create_OrgTokenWithoutProjectIsRejected(t *testing.T) { + t.Parallel() + + svc := &APITokenService{service: newService()} + + ctx := context.Background() + ctx = entities.WithCurrentOrg(ctx, &entities.Org{ID: uuid.NewString()}) + ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{ID: uuid.NewString(), ProjectID: nil}) + + req := &pb.APITokenServiceCreateRequest{Name: "test-token"} + + _, err := svc.Create(ctx, req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "org-level API tokens must specify a project") +} + +func TestAPITokenService_List_OrgTokenForcesProjectScope(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token *entities.APIToken + wantScope biz.APITokenScope + }{ + { + name: "org-level token forces project scope", + token: &entities.APIToken{ID: uuid.NewString(), ProjectID: nil}, + wantScope: biz.APITokenScopeProject, + }, + { + name: "project-scoped token does not override scope", + token: &entities.APIToken{ID: uuid.NewString(), ProjectID: toUUIDPtr(uuid.New())}, + wantScope: "", // mapTokenScope returns "" for SCOPE_UNSPECIFIED + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + ctx = entities.WithCurrentAPIToken(ctx, tc.token) + + scope := mapTokenScope(pb.APITokenServiceListRequest_SCOPE_UNSPECIFIED) + if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil { + scope = biz.APITokenScopeProject + } + + assert.Equal(t, tc.wantScope, scope) + }) + } +} + +func TestAPITokenService_Revoke_OrgTokenCannotRevokeOrgTokens(t *testing.T) { + t.Parallel() + + orgID := uuid.NewString() + + tests := []struct { + name string + callerToken *entities.APIToken + targetToken *biz.APIToken + wantForbidden bool + }{ + { + name: "org-level token revoking org-level token is forbidden", + callerToken: &entities.APIToken{ID: uuid.NewString(), ProjectID: nil}, + targetToken: &biz.APIToken{ + ID: uuid.New(), + OrganizationID: uuid.MustParse(orgID), + ProjectID: nil, + }, + wantForbidden: true, + }, + { + name: "org-level token revoking project token is allowed", + callerToken: &entities.APIToken{ID: uuid.NewString(), ProjectID: nil}, + targetToken: &biz.APIToken{ + ID: uuid.New(), + OrganizationID: uuid.MustParse(orgID), + ProjectID: toUUIDPtr(uuid.New()), + }, + wantForbidden: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + ctx = entities.WithCurrentAPIToken(ctx, tc.callerToken) + + forbidden := false + if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil { + if tc.targetToken.ProjectID == nil { + forbidden = true + } + } + + assert.Equal(t, tc.wantForbidden, forbidden) + }) + } +} + +func toUUIDPtr(id uuid.UUID) *uuid.UUID { + return &id +} diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index 5c376afe5..0d6ac86b7 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -35,6 +35,14 @@ type APITokenJWTConfig struct { SymmetricHmacKey string } +// orgLevelTokenPolicies are additional policies granted only to org-level tokens. +// They allow managing project-scoped tokens. +var orgLevelTokenPolicies = []*authz.Policy{ + authz.PolicyAPITokenCreate, + authz.PolicyAPITokenList, + authz.PolicyAPITokenRevoke, +} + // APIToken is used for unattended access to the control plane API. type APIToken struct { ID uuid.UUID @@ -204,6 +212,11 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description policies = uc.DefaultAuthzPolicies } + // Org-level tokens additionally get project-level API token management policies + if projectID == nil && orgUUID != nil { + policies = append(policies, orgLevelTokenPolicies...) + } + // NOTE: the expiration time is stored just for reference, it's also encoded in the JWT // We store it since Chainloop will not have access to the JWT to check the expiration once created token, err := uc.apiTokenRepo.Create(ctx, name, description, expiresAt, orgUUID, projectID, policies) diff --git a/app/controlplane/pkg/biz/apitoken_integration_test.go b/app/controlplane/pkg/biz/apitoken_integration_test.go index 2175188ad..9a6416edc 100644 --- a/app/controlplane/pkg/biz/apitoken_integration_test.go +++ b/app/controlplane/pkg/biz/apitoken_integration_test.go @@ -21,6 +21,7 @@ import ( "testing" "time" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" "github.com/golang-jwt/jwt/v4" @@ -144,26 +145,55 @@ func (s *apiTokenTestSuite) TestCreate() { } func (s *apiTokenTestSuite) TestAuthzPolicies() { - // a new token has a new set of policies associated - token, err := s.APIToken.Create(context.Background(), randomName(), nil, nil, &s.org.ID) - require.NoError(s.T(), err) + ctx := context.Background() - // With the new architecture, API token policies are stored in the database, not in Casbin - // Verify that the token has the default policies stored - s.Require().NotNil(token.Policies) - s.Len(token.Policies, len(s.APIToken.DefaultAuthzPolicies)) - - // Check that all default policies are present - for _, expectedPolicy := range s.APIToken.DefaultAuthzPolicies { - found := false - for _, actualPolicy := range token.Policies { - if actualPolicy.Resource == expectedPolicy.Resource && actualPolicy.Action == expectedPolicy.Action { - found = true - break + s.Run("project-level token gets default policies", func() { + token, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID, biz.APITokenWithProject(s.p1)) + require.NoError(s.T(), err) + s.Require().NotNil(token.Policies) + s.Len(token.Policies, len(s.APIToken.DefaultAuthzPolicies)) + + for _, p := range s.APIToken.DefaultAuthzPolicies { + found := false + for _, actual := range token.Policies { + if actual.Resource == p.Resource && actual.Action == p.Action { + found = true + break + } } + s.True(found, fmt.Sprintf("policy %s:%s not found", p.Resource, p.Action)) } - s.True(found, fmt.Sprintf("policy %s:%s not found", expectedPolicy.Resource, expectedPolicy.Action)) - } + }) + + s.Run("org-level token gets default plus token management policies", func() { + token, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID) + require.NoError(s.T(), err) + s.Require().NotNil(token.Policies) + + // All default policies must be present + for _, p := range s.APIToken.DefaultAuthzPolicies { + found := false + for _, actual := range token.Policies { + if actual.Resource == p.Resource && actual.Action == p.Action { + found = true + break + } + } + s.True(found, fmt.Sprintf("default policy %s:%s not found", p.Resource, p.Action)) + } + + // Additional org-level token management policies must be present + for _, p := range []*authz.Policy{authz.PolicyAPITokenCreate, authz.PolicyAPITokenList, authz.PolicyAPITokenRevoke} { + found := false + for _, actual := range token.Policies { + if actual.Resource == p.Resource && actual.Action == p.Action { + found = true + break + } + } + s.True(found, fmt.Sprintf("org policy %s:%s not found", p.Resource, p.Action)) + } + }) } func (s *apiTokenTestSuite) TestRevoke() { diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260303120000.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260303120000.sql new file mode 100644 index 000000000..29bb9ab81 --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260303120000.sql @@ -0,0 +1,10 @@ +-- Add API token management policies to existing org-level tokens. +UPDATE "api_tokens" +SET "policies" = "policies" || '[ + {"Resource": "api_token", "Action": "create"}, + {"Resource": "api_token", "Action": "list"}, + {"Resource": "api_token", "Action": "delete"} +]'::jsonb +WHERE "policies" IS NOT NULL + AND "organization_id" IS NOT NULL + AND "project_id" IS NULL; diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index e5390dda9..0285ca20a 100644 --- a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum +++ b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:zokBUIf1GE1Ih+WH8XycjROBmH2uLn+Kh6sw9vVhTl4= +h1:SDVo/094SOGNvK1KK35O4KpUY78MuSTZljVN58UGkO0= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -125,3 +125,4 @@ h1:zokBUIf1GE1Ih+WH8XycjROBmH2uLn+Kh6sw9vVhTl4= 20260112115927.sql h1:/RKhzT5dRphgeBitxBfo3a3fqLVgvmVZxxqe9fH8lkg= 20260204113827.sql h1:rlJNf8QRfqOfDHf2GUi+59Rgv2BkSbMTPuMalPsMkZg= 20260211225609.sql h1:DTkyg3oZSV99uPGl+vOuK9FSlEumXwoYCgchUhsg/P4= +20260303120000.sql h1:msXy2MRkzMOGxWbG1NOHh+PN5qjaBZcRzVT+7SFIwaA=