From e823afa804709eb3df41a322d719382d0b0ebefa Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Tue, 29 Sep 2026 13:25:06 +0200 Subject: [PATCH 1/8] feat(api-token): record what every new token is scoped to Adds scope and scope_id to API tokens. Every new token records its kind (organization, project, instance or product) and the resource it names. Product-scoped token names are unique within their product, and CHECK constraints keep a scope coherent with the organization and project on the row. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 9f11d7f0-7bde-4cda-84d4-102c5ecf87d3 --- app/controlplane/internal/service/apitoken.go | 10 +- .../internal/service/apitoken_test.go | 174 +++++++++- app/controlplane/pkg/biz/apitoken.go | 127 ++++++- .../pkg/biz/apitoken_integration_test.go | 322 +++++++++++++++++- .../pkg/biz/apitoken_validate_scope_test.go | 72 ++++ .../pkg/biz/mocks/APITokenRepo.go | 93 ++--- app/controlplane/pkg/data/apitoken.go | 43 ++- app/controlplane/pkg/data/ent/apitoken.go | 32 +- .../pkg/data/ent/apitoken/apitoken.go | 28 ++ .../pkg/data/ent/apitoken/where.go | 96 ++++++ .../pkg/data/ent/apitoken_create.go | 161 +++++++++ .../pkg/data/ent/apitoken_update.go | 130 +++++++ .../ent/migrate/migrations/20260929111949.sql | 11 + .../pkg/data/ent/migrate/migrations/atlas.sum | 3 +- .../pkg/data/ent/migrate/schema.go | 27 +- app/controlplane/pkg/data/ent/mutation.go | 148 +++++++- app/controlplane/pkg/data/ent/runtime.go | 2 +- .../pkg/data/ent/schema/apitoken.go | 41 ++- 18 files changed, 1384 insertions(+), 136 deletions(-) create mode 100644 app/controlplane/pkg/biz/apitoken_validate_scope_test.go create mode 100644 app/controlplane/pkg/data/ent/migrate/migrations/20260929111949.sql diff --git a/app/controlplane/internal/service/apitoken.go b/app/controlplane/internal/service/apitoken.go index 4642d6484..2d02d0564 100644 --- a/app/controlplane/internal/service/apitoken.go +++ b/app/controlplane/internal/service/apitoken.go @@ -111,7 +111,7 @@ func (s *APITokenService) List(ctx context.Context, req *pb.APITokenServiceListR // 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 + scope = authz.ResourceTypeProject } tokens, err := s.APITokenUseCase.List(ctx, currentOrg.ID, biz.WithAPITokenStatusFilter(mapTokenStatusFilter(req.GetStatusFilter())), biz.WithAPITokenProjectFilter(defaultProjectFilter), biz.WithAPITokenScope(scope)) @@ -127,12 +127,12 @@ func (s *APITokenService) List(ctx context.Context, req *pb.APITokenServiceListR return &pb.APITokenServiceListResponse{Result: result}, nil } -func mapTokenScope(scope pb.APITokenServiceListRequest_Scope) biz.APITokenScope { +func mapTokenScope(scope pb.APITokenServiceListRequest_Scope) authz.ResourceType { switch scope { case pb.APITokenServiceListRequest_SCOPE_PROJECT: - return biz.APITokenScopeProject + return authz.ResourceTypeProject case pb.APITokenServiceListRequest_SCOPE_GLOBAL: - return biz.APITokenScopeGlobal + return authz.ResourceTypeOrganization } return "" @@ -215,7 +215,7 @@ func apiTokenBizToPb(in *biz.APIToken) *pb.APITokenItem { if in.ProjectID != nil { res.ScopedEntity = &pb.ScopedEntity{ - Type: string(biz.ContractScopeProject), + Type: string(authz.ResourceTypeProject), Id: in.ProjectID.String(), Name: *in.ProjectName, } diff --git a/app/controlplane/internal/service/apitoken_test.go b/app/controlplane/internal/service/apitoken_test.go index f148d5284..12b91e6e7 100644 --- a/app/controlplane/internal/service/apitoken_test.go +++ b/app/controlplane/internal/service/apitoken_test.go @@ -18,12 +18,18 @@ package service import ( "context" "testing" + "time" pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + bizMocks "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/mocks" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/usercontext/entities" "github.com/google/uuid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) func TestAPITokenService_Create_OrgTokenWithoutProjectIsRejected(t *testing.T) { @@ -42,37 +48,75 @@ func TestAPITokenService_Create_OrgTokenWithoutProjectIsRejected(t *testing.T) { assert.Contains(t, err.Error(), "org-level API tokens must specify a project") } -func TestAPITokenService_List_OrgTokenForcesProjectScope(t *testing.T) { +// An organization-wide token may only list project tokens, whatever scope it asks for; every +// other caller gets the scope it asked for. Driven through List itself, down to the filters the +// repository receives, so the override cannot drift away from what is asserted here. +func TestAPITokenServiceListForcesProjectScopeForOrgTokens(t *testing.T) { t.Parallel() - tests := []struct { - name string - token *entities.APIToken - wantScope biz.APITokenScope + orgID, projectID := uuid.New(), uuid.New() + + testCases := []struct { + name string + // caller is the API token making the request; nil means a user + caller *entities.APIToken + requested pb.APITokenServiceListRequest_Scope + wantScope authz.ResourceType + wantProjects []uuid.UUID }{ { - name: "org-level token forces project scope", - token: &entities.APIToken{ID: uuid.NewString(), ProjectID: nil}, - wantScope: biz.APITokenScopeProject, + name: "an organization token is forced to project tokens", + caller: &entities.APIToken{ID: uuid.NewString()}, + wantScope: authz.ResourceTypeProject, + }, + { + name: "an organization token asking for global tokens is still forced", + caller: &entities.APIToken{ID: uuid.NewString()}, + requested: pb.APITokenServiceListRequest_SCOPE_GLOBAL, + wantScope: authz.ResourceTypeProject, }, { - name: "project-scoped token does not override scope", - token: &entities.APIToken{ID: uuid.NewString(), ProjectID: toUUIDPtr(uuid.New())}, - wantScope: "", // mapTokenScope returns "" for SCOPE_UNSPECIFIED + name: "a project token keeps the scope it asks for", + caller: &entities.APIToken{ID: uuid.NewString(), ProjectID: &projectID}, + requested: pb.APITokenServiceListRequest_SCOPE_GLOBAL, + wantScope: authz.ResourceTypeOrganization, + wantProjects: []uuid.UUID{projectID}, + }, + { + name: "a user keeps the scope it asks for", + requested: pb.APITokenServiceListRequest_SCOPE_GLOBAL, + wantScope: authz.ResourceTypeOrganization, + }, + { + name: "a user asking for no scope gets none", + wantScope: "", }, } - for _, tc := range tests { + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() - ctx = entities.WithCurrentAPIToken(ctx, tc.token) + t.Parallel() - scope := mapTokenScope(pb.APITokenServiceListRequest_SCOPE_UNSPECIFIED) - if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil { - scope = biz.APITokenScopeProject + var got *biz.APITokenListFilters + repo := bizMocks.NewAPITokenRepo(t) + repo.On("List", mock.Anything, mock.Anything, mock.Anything).Once(). + Run(func(args mock.Arguments) { got = args.Get(2).(*biz.APITokenListFilters) }). + Return([]*biz.APIToken{}, nil) + uc, err := biz.NewAPITokenUseCase(repo, &biz.APITokenJWTConfig{SymmetricHmacKey: "test"}, nil, nil, nil, nil) + require.NoError(t, err) + + ctx := entities.WithCurrentOrg(context.Background(), &entities.Org{ID: orgID.String(), Name: "acme"}) + if tc.caller != nil { + ctx = entities.WithCurrentAPIToken(ctx, tc.caller) + } else { + ctx = usercontext.WithAuthzSubject(ctx, string(authz.RoleAdmin)) } - assert.Equal(t, tc.wantScope, scope) + _, err = NewAPITokenService(uc).List(ctx, &pb.APITokenServiceListRequest{Scope: tc.requested}) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, tc.wantScope, got.FilterByScope) + assert.Equal(t, tc.wantProjects, got.FilterByProjects) }) } } @@ -130,3 +174,97 @@ func TestAPITokenService_Revoke_OrgTokenCannotRevokeOrgTokens(t *testing.T) { func toUUIDPtr(id uuid.UUID) *uuid.UUID { return &id } + +// A listing reports the project a token is confined to, and the scope columns change nothing +// about it: every new token records a scope, yet each one lists exactly as it did before. +func TestAPITokenBizToPbScopedEntity(t *testing.T) { + t.Parallel() + + projectID, productID, orgID := uuid.New(), uuid.New(), uuid.New() + createdAt := time.Now() + + testCases := []struct { + name string + token *biz.APIToken + want *pb.ScopedEntity + }{ + { + name: "a project-scoped token reports its project", + token: &biz.APIToken{ + ID: uuid.New(), CreatedAt: &createdAt, + ProjectID: &projectID, ProjectName: biz.ToPtr("billing"), + }, + want: &pb.ScopedEntity{Type: string(authz.ResourceTypeProject), Id: projectID.String(), Name: "billing"}, + }, + { + name: "a scope id without a kind is not reported", + token: &biz.APIToken{ + ID: uuid.New(), CreatedAt: &createdAt, + ScopeID: &productID, + }, + want: nil, + }, + { + // New tokens record their scope for every kind, but only a product is reported + // from it: an organization token lists exactly as it did before. + name: "an organization-scoped token reports none", + token: &biz.APIToken{ + ID: uuid.New(), CreatedAt: &createdAt, + Scope: biz.ToPtr(authz.ResourceTypeOrganization), ScopeID: &orgID, + }, + want: nil, + }, + { + name: "a project-scoped token still reports its project from project_id", + token: &biz.APIToken{ + ID: uuid.New(), CreatedAt: &createdAt, + ProjectID: &projectID, ProjectName: biz.ToPtr("billing"), + Scope: biz.ToPtr(authz.ResourceTypeProject), ScopeID: &projectID, + }, + want: &pb.ScopedEntity{Type: string(authz.ResourceTypeProject), Id: projectID.String(), Name: "billing"}, + }, + { + name: "an organization-level token reports none", + token: &biz.APIToken{ID: uuid.New(), CreatedAt: &createdAt}, + want: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := apiTokenBizToPb(tc.token).GetScopedEntity() + if tc.want == nil { + assert.Nil(t, got) + return + } + + require.NotNil(t, got) + assert.Equal(t, tc.want.GetType(), got.GetType()) + assert.Equal(t, tc.want.GetId(), got.GetId()) + assert.Equal(t, tc.want.GetName(), got.GetName()) + }) + } +} + +// The public listing scopes map onto resource kinds; "global" is the organization's own tokens. +func TestMapTokenScope(t *testing.T) { + t.Parallel() + + testCases := []struct { + in pb.APITokenServiceListRequest_Scope + want authz.ResourceType + }{ + {in: pb.APITokenServiceListRequest_SCOPE_UNSPECIFIED, want: ""}, + {in: pb.APITokenServiceListRequest_SCOPE_PROJECT, want: authz.ResourceTypeProject}, + {in: pb.APITokenServiceListRequest_SCOPE_GLOBAL, want: authz.ResourceTypeOrganization}, + } + + for _, tc := range testCases { + t.Run(tc.in.String(), func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, mapTokenScope(tc.in)) + }) + } +} diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index f57ffd116..7a741ac3e 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -96,14 +96,35 @@ type APIToken struct { // If the token is scoped to a specific workflow within a project WorkflowID *uuid.UUID WorkflowName *string + // What the token is scoped to: organization, project, instance or product. Only a product + // scope drives any logic for now; tokens from before these columns existed leave both NULL. + // A product's name is not stored: it belongs to whoever owns the product. + Scope *authz.ResourceType + ScopeID *uuid.UUID // ACL policies for this token Policies []*authz.Policy // IsSystem marks tokens minted by internal code paths; these are hidden from the public API. IsSystem bool } +// APITokenCreateOpts is everything the repository persists for a new token. +type APITokenCreateOpts struct { + Name string + Description *string + ExpiresAt *time.Time + OrganizationID *uuid.UUID + ProjectID *uuid.UUID + WorkflowID *uuid.UUID + // Scope records what the token is scoped to. ScopeID names that resource, and is unset only + // for an instance-level token. + Scope *authz.ResourceType + ScopeID *uuid.UUID + Policies []*authz.Policy + IsSystem bool +} + type APITokenRepo interface { - Create(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, workflowID *uuid.UUID, policies []*authz.Policy, isSystem bool) (*APIToken, error) + Create(ctx context.Context, opts *APITokenCreateOpts) (*APIToken, error) List(ctx context.Context, orgID *uuid.UUID, filters *APITokenListFilters) ([]*APIToken, error) Revoke(ctx context.Context, orgID *uuid.UUID, ID uuid.UUID) error // FindInactive returns tokens in an organization that have been inactive since the given cutoff time. @@ -154,6 +175,8 @@ func NewAPITokenUseCase(apiTokenRepo APITokenRepo, jwtConfig *APITokenJWTConfig, type apiTokenOptions struct { project *Project workflow *Workflow + scope *authz.ResourceType + scopeID *uuid.UUID policies []*authz.Policy isSystem bool } @@ -188,6 +211,58 @@ func APITokenAsSystem() APITokenCreateOpt { } } +// APITokenWithScope names what the token is scoped to. scopeID identifies that resource and is +// nil only for an instance scope. The scope must agree with the organization and project the +// token is created for; without this option it is derived from them. +func APITokenWithScope(scope authz.ResourceType, scopeID *uuid.UUID) APITokenCreateOpt { + return func(o *apiTokenOptions) { + o.scope = &scope + o.scopeID = scopeID + } +} + +// validateTokenScope checks that an explicit scope agrees with the organization and project the +// token is created for, which stay the fields the control plane reads for these kinds. +func validateTokenScope(scope authz.ResourceType, scopeID, orgID, projectID *uuid.UUID) error { + switch scope { + case authz.ResourceTypeOrganization: + if orgID == nil || projectID != nil || scopeID == nil || *scopeID != *orgID { + return NewErrValidationStr("an organization scope must name the organization of an organization-level token") + } + case authz.ResourceTypeProject: + if projectID == nil || scopeID == nil || *scopeID != *projectID { + return NewErrValidationStr("a project scope must name the project the token is created for") + } + case authz.ResourceTypeInstance: + if orgID != nil || projectID != nil || scopeID != nil { + return NewErrValidationStr("an instance scope has no id and belongs to an instance-level token") + } + case authz.ResourceTypeProduct: + // Not until the control plane confines such a token to its memberships: everything + // else in it would read a token with no project as organization-wide. + return NewErrValidationStr(fmt.Sprintf("unsupported token scope %q", scope)) + default: + return NewErrValidationStr(fmt.Sprintf("unsupported token scope %q", scope)) + } + + return nil +} + +// newTokenScope is the scope recorded for a new token confined to the given organization and +// project. Only a product scope drives any logic for now: for these kinds the columns mirror +// project_id and organization_id, which stay the fields the control plane reads, and tokens +// from before the columns existed keep both NULL. +func newTokenScope(orgID, projectID *uuid.UUID) (*authz.ResourceType, *uuid.UUID) { + switch { + case projectID != nil: + return ToPtr(authz.ResourceTypeProject), projectID + case orgID != nil: + return ToPtr(authz.ResourceTypeOrganization), orgID + default: + return ToPtr(authz.ResourceTypeInstance), nil + } +} + // expires in is a string that can be parsed by time.ParseDuration func (uc *APITokenUseCase) Create(ctx context.Context, name string, description *string, expiresIn *time.Duration, orgID *string, opts ...APITokenCreateOpt) (*APIToken, error) { ctx, span := otelx.Start(ctx, apiTokenTracer, "APITokenUseCase.Create") @@ -260,9 +335,29 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description policies = slices.Concat(policies, orgLevelTokenPolicies) } + scope, scopeID := newTokenScope(orgUUID, projectID) + if options.scope != nil { + if err := validateTokenScope(*options.scope, options.scopeID, orgUUID, projectID); err != nil { + return nil, err + } + + scope, scopeID = options.scope, options.scopeID + } + // 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, workflowID, policies, options.isSystem) + token, err := uc.apiTokenRepo.Create(ctx, &APITokenCreateOpts{ + Name: name, + Description: description, + ExpiresAt: expiresAt, + OrganizationID: orgUUID, + ProjectID: projectID, + WorkflowID: workflowID, + Scope: scope, + ScopeID: scopeID, + Policies: policies, + IsSystem: options.isSystem, + }) if err != nil { if IsErrAlreadyExists(err) { return nil, NewErrAlreadyExistsStr("name already taken") @@ -387,7 +482,9 @@ func WithAPITokenStatusFilter(filter APITokenStatusFilter) APITokenListOpt { } } -func WithAPITokenScope(scope APITokenScope) APITokenListOpt { +// WithAPITokenScope selects the tokens scoped to the given kind of resource. Organization +// selects the organization-wide tokens, from before and after the scope columns existed. +func WithAPITokenScope(scope authz.ResourceType) APITokenListOpt { return func(opts *APITokenListFilters) { opts.FilterByScope = scope } @@ -401,18 +498,12 @@ func WithIncludeSystemTokens() APITokenListOpt { } } -type APITokenScope string - -const ( - APITokenScopeProject APITokenScope = "project" - APITokenScopeGlobal APITokenScope = "global" - APITokenScopeInstance APITokenScope = "instance" -) - -var availableAPITokenScopes = []APITokenScope{ - APITokenScopeProject, - APITokenScopeGlobal, - APITokenScopeInstance, +// listableAPITokenScopes are the kinds of resource a token listing can be scoped to. +var listableAPITokenScopes = []authz.ResourceType{ + authz.ResourceTypeProject, + authz.ResourceTypeProduct, + authz.ResourceTypeOrganization, + authz.ResourceTypeInstance, } // APITokenStatusFilter controls which tokens are returned based on their revocation status. @@ -435,7 +526,7 @@ type APITokenListFilters struct { // Defaults to APITokenStatusFilterActive. StatusFilter APITokenStatusFilter // FilterByScope is used to filter the result by the scope of the token - FilterByScope APITokenScope + FilterByScope authz.ResourceType // IncludeSystem controls whether system-managed tokens are returned. // Defaults to false (system tokens are hidden). IncludeSystem bool @@ -450,8 +541,8 @@ func (uc *APITokenUseCase) List(ctx context.Context, orgID string, opts ...APITo opt(filters) } - if filters.FilterByScope != "" && !slices.Contains(availableAPITokenScopes, filters.FilterByScope) { - return nil, NewErrValidationStr(fmt.Sprintf("invalid scope %q, please chose one of: %v", filters.FilterByScope, availableAPITokenScopes)) + if filters.FilterByScope != "" && !slices.Contains(listableAPITokenScopes, filters.FilterByScope) { + return nil, NewErrValidationStr(fmt.Sprintf("invalid scope %q, please chose one of: %v", filters.FilterByScope, listableAPITokenScopes)) } var orgUUID *uuid.UUID diff --git a/app/controlplane/pkg/biz/apitoken_integration_test.go b/app/controlplane/pkg/biz/apitoken_integration_test.go index 23f1f4653..8c042a5c0 100644 --- a/app/controlplane/pkg/biz/apitoken_integration_test.go +++ b/app/controlplane/pkg/biz/apitoken_integration_test.go @@ -340,13 +340,13 @@ func (s *apiTokenTestSuite) TestList() { }) s.Run("can return scoped to a project", func() { - tokens, err := s.APIToken.List(ctx, s.org.ID, biz.WithAPITokenScope(biz.APITokenScopeProject)) + tokens, err := s.APIToken.List(ctx, s.org.ID, biz.WithAPITokenScope(authz.ResourceTypeProject)) s.NoError(err) require.Len(s.T(), tokens, 3) }) s.Run("can return scoped to a global", func() { - tokens, err := s.APIToken.List(ctx, s.org.ID, biz.WithAPITokenScope(biz.APITokenScopeGlobal)) + tokens, err := s.APIToken.List(ctx, s.org.ID, biz.WithAPITokenScope(authz.ResourceTypeOrganization)) s.NoError(err) s.Len(tokens, 2) }) @@ -531,3 +531,321 @@ func (s *apiTokenTestSuite) TestUpdateLastUsedAt() { s.True(biz.IsNotFound(err)) }) } + +// A product scope is written by the platform and read back as stored. +func (s *apiTokenTestSuite) TestRepoPersistsAndReadsTheResourceScope() { + ctx := context.Background() + productID := uuid.New() + orgUUID := uuid.MustParse(s.org.ID) + + created, err := s.Repos.APITokenRepo.Create(ctx, &biz.APITokenCreateOpts{ + Name: randomName(), + OrganizationID: &orgUUID, + Scope: biz.ToPtr(authz.ResourceTypeProduct), + ScopeID: &productID, + Policies: []*authz.Policy{}, + }) + s.Require().NoError(err) + + s.Require().NotNil(created.Scope) + s.Equal(authz.ResourceTypeProduct, *created.Scope) + s.Require().NotNil(created.ScopeID) + s.Equal(productID, *created.ScopeID) + // A scoped token is confined to neither a project nor a workflow. + s.Nil(created.ProjectID) + s.Nil(created.WorkflowID) + + reloaded, err := s.Repos.APITokenRepo.FindByID(ctx, created.ID) + s.Require().NoError(err) + s.Require().NotNil(reloaded.ScopeID) + s.Equal(productID, *reloaded.ScopeID) +} + +// A caller such as the platform may name the scope itself. It must agree with the +// organization and project the token is created for, and is refused otherwise. +func (s *apiTokenTestSuite) TestCreateWithAnExplicitScope() { + ctx := context.Background() + orgUUID := uuid.MustParse(s.org.ID) + otherOrg := uuid.MustParse(s.org2.ID) + productID := uuid.New() + opts := func(o ...biz.APITokenCreateOpt) []biz.APITokenCreateOpt { return o } + + testCases := []struct { + name string + org *string + opts []biz.APITokenCreateOpt + wantScope authz.ResourceType + wantScopeID *uuid.UUID + wantErr bool + }{ + { + name: "an organization scope naming its organization", org: &s.org.ID, + opts: opts(biz.APITokenWithScope(authz.ResourceTypeOrganization, &orgUUID)), + wantScope: authz.ResourceTypeOrganization, wantScopeID: &orgUUID, + }, + { + name: "a project scope naming its project", org: &s.org.ID, + opts: opts(biz.APITokenWithProject(s.p1), biz.APITokenWithScope(authz.ResourceTypeProject, &s.p1.ID)), + wantScope: authz.ResourceTypeProject, wantScopeID: &s.p1.ID, + }, + { + name: "an instance scope has no id", + opts: opts(biz.APITokenWithScope(authz.ResourceTypeInstance, nil)), + wantScope: authz.ResourceTypeInstance, + }, + + {name: "an organization scope naming another organization", org: &s.org.ID, opts: opts(biz.APITokenWithScope(authz.ResourceTypeOrganization, &otherOrg)), wantErr: true}, + {name: "an organization scope with no id", org: &s.org.ID, opts: opts(biz.APITokenWithScope(authz.ResourceTypeOrganization, nil)), wantErr: true}, + {name: "an organization scope on a project token", org: &s.org.ID, opts: opts(biz.APITokenWithProject(s.p1), biz.APITokenWithScope(authz.ResourceTypeOrganization, &orgUUID)), wantErr: true}, + {name: "an organization scope on an instance-level token", opts: opts(biz.APITokenWithScope(authz.ResourceTypeOrganization, &orgUUID)), wantErr: true}, + {name: "a project scope without its project", org: &s.org.ID, opts: opts(biz.APITokenWithScope(authz.ResourceTypeProject, &s.p1.ID)), wantErr: true}, + {name: "a project scope naming another project", org: &s.org.ID, opts: opts(biz.APITokenWithProject(s.p1), biz.APITokenWithScope(authz.ResourceTypeProject, &s.p2.ID)), wantErr: true}, + {name: "an instance scope with an id", opts: opts(biz.APITokenWithScope(authz.ResourceTypeInstance, &productID)), wantErr: true}, + {name: "an instance scope on an organization token", org: &s.org.ID, opts: opts(biz.APITokenWithScope(authz.ResourceTypeInstance, nil)), wantErr: true}, + // A product-scoped token must not be minted before the control plane confines one to + // its memberships: the rest of it would read the token as organization-wide. + {name: "a product scope is not supported yet", org: &s.org.ID, opts: opts(biz.APITokenWithScope(authz.ResourceTypeProduct, &productID)), wantErr: true}, + {name: "a kind tokens are never scoped to", org: &s.org.ID, opts: opts(biz.APITokenWithScope(authz.ResourceTypeGroup, &productID)), wantErr: true}, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + created, err := s.APIToken.Create(ctx, randomName(), nil, nil, tc.org, tc.opts...) + if tc.wantErr { + s.Require().Error(err) + s.True(biz.IsErrValidation(err), "want a validation error, got %v", err) + s.Nil(created) + return + } + + s.Require().NoError(err) + stored, err := s.Repos.APITokenRepo.FindByID(ctx, created.ID) + s.Require().NoError(err) + for _, got := range []*biz.APIToken{created, stored} { + s.Require().NotNil(got.Scope) + s.Equal(tc.wantScope, *got.Scope) + s.Equal(tc.wantScopeID, got.ScopeID) + } + }) + } +} + +// Naming the organization scope explicitly mints the same token as leaving it implied, +// organization-level policies included. +func (s *apiTokenTestSuite) TestAnExplicitOrganizationScopeKeepsTheOrganizationPolicies() { + ctx := context.Background() + orgUUID := uuid.MustParse(s.org.ID) + + implied, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID) + s.Require().NoError(err) + explicit, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID, + biz.APITokenWithScope(authz.ResourceTypeOrganization, &orgUUID)) + s.Require().NoError(err) + + s.ElementsMatch(implied.Policies, explicit.Policies) +} + +// Listing scopes are resource kinds; anything tokens are not listed by is refused. +func (s *apiTokenTestSuite) TestListRejectsAScopeTokensAreNotListedBy() { + ctx := context.Background() + for _, scope := range []authz.ResourceType{authz.ResourceTypeGroup, "global", "nonsense"} { + _, err := s.APIToken.List(ctx, s.org.ID, biz.WithAPITokenScope(scope)) + s.Require().Error(err, scope) + s.True(biz.IsErrValidation(err), "scope %q: want a validation error, got %v", scope, err) + } +} + +// Every token minted from now on records what it is scoped to, so the columns can later back +// a single implementation. Only a product scope drives any logic for now: for the other kinds +// they mirror project_id and organization_id, which stay the fields the control plane reads. +func (s *apiTokenTestSuite) TestCreateRecordsTheScopeOfEveryNewToken() { + ctx := context.Background() + orgUUID := uuid.MustParse(s.org.ID) + + wf, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{Name: randomName(), OrgID: s.org.ID, Project: s.p1.Name}) + s.Require().NoError(err) + + testCases := []struct { + name string + org *string + opts []biz.APITokenCreateOpt + wantScope authz.ResourceType + wantScopeID *uuid.UUID + wantProject *uuid.UUID + }{ + {name: "an organization-level token", org: &s.org.ID, wantScope: authz.ResourceTypeOrganization, wantScopeID: &orgUUID}, + {name: "a project token", org: &s.org.ID, opts: []biz.APITokenCreateOpt{biz.APITokenWithProject(s.p1)}, wantScope: authz.ResourceTypeProject, wantScopeID: &s.p1.ID, wantProject: &s.p1.ID}, + {name: "a workflow-pinned token is scoped to its project", org: &s.org.ID, opts: []biz.APITokenCreateOpt{biz.APITokenWithProject(s.p1), biz.APITokenWithWorkflow(wf)}, wantScope: authz.ResourceTypeProject, wantScopeID: &s.p1.ID, wantProject: &s.p1.ID}, + {name: "an instance-level token has a kind but no id", wantScope: authz.ResourceTypeInstance}, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + created, err := s.APIToken.Create(ctx, randomName(), nil, nil, tc.org, tc.opts...) + s.Require().NoError(err) + + stored, err := s.Repos.APITokenRepo.FindByID(ctx, created.ID) + s.Require().NoError(err) + for _, got := range []*biz.APIToken{created, stored} { + s.Require().NotNil(got.Scope) + s.Equal(tc.wantScope, *got.Scope) + s.Equal(tc.wantScopeID, got.ScopeID) + // The fields the existing logic reads are unchanged. + s.Equal(tc.wantProject, got.ProjectID) + } + }) + } +} + +// The scope must agree with the row it is on. The database holds that because the platform +// writes these rows from outside this module; a refused row is malformed, not a name clash. +// Rows from before this change carry no scope at all and are untouched. +func (s *apiTokenTestSuite) TestRepoScopeMustAgreeWithTheToken() { + ctx := context.Background() + orgUUID := uuid.MustParse(s.org.ID) + otherOrg := uuid.MustParse(s.org2.ID) + productID := uuid.New() + + testCases := []struct { + name string + org *uuid.UUID + projectID *uuid.UUID + scope *authz.ResourceType + scopeID *uuid.UUID + wantErr bool + }{ + {name: "a token from before this change carries no scope", org: &orgUUID}, + {name: "an organization scope naming its organization", org: &orgUUID, scope: biz.ToPtr(authz.ResourceTypeOrganization), scopeID: &orgUUID}, + {name: "a project scope naming its project", org: &orgUUID, projectID: &s.p1.ID, scope: biz.ToPtr(authz.ResourceTypeProject), scopeID: &s.p1.ID}, + {name: "an instance scope with no id", scope: biz.ToPtr(authz.ResourceTypeInstance)}, + {name: "a product scope", org: &orgUUID, scope: biz.ToPtr(authz.ResourceTypeProduct), scopeID: &productID}, + + {name: "an organization scope naming another organization", org: &orgUUID, scope: biz.ToPtr(authz.ResourceTypeOrganization), scopeID: &otherOrg, wantErr: true}, + {name: "an organization scope on a project token", org: &orgUUID, projectID: &s.p1.ID, scope: biz.ToPtr(authz.ResourceTypeOrganization), scopeID: &orgUUID, wantErr: true}, + {name: "an organization scope with no id", org: &orgUUID, scope: biz.ToPtr(authz.ResourceTypeOrganization), wantErr: true}, + {name: "a project scope naming another project", org: &orgUUID, projectID: &s.p1.ID, scope: biz.ToPtr(authz.ResourceTypeProject), scopeID: &s.p2.ID, wantErr: true}, + {name: "a project scope on a token with no project", org: &orgUUID, scope: biz.ToPtr(authz.ResourceTypeProject), scopeID: &s.p1.ID, wantErr: true}, + {name: "an instance scope with an id", scope: biz.ToPtr(authz.ResourceTypeInstance), scopeID: &productID, wantErr: true}, + {name: "an instance scope on an organization token", org: &orgUUID, scope: biz.ToPtr(authz.ResourceTypeInstance), wantErr: true}, + {name: "a product scope with no id", org: &orgUUID, scope: biz.ToPtr(authz.ResourceTypeProduct), wantErr: true}, + {name: "a product scope alongside a project", org: &orgUUID, projectID: &s.p1.ID, scope: biz.ToPtr(authz.ResourceTypeProduct), scopeID: &productID, wantErr: true}, + {name: "a product scope with no organization", scope: biz.ToPtr(authz.ResourceTypeProduct), scopeID: &productID, wantErr: true}, + {name: "a kind tokens are never scoped to", org: &orgUUID, scope: biz.ToPtr(authz.ResourceTypeGroup), scopeID: &productID, wantErr: true}, + {name: "a scope id without a kind", org: &orgUUID, scopeID: &productID, wantErr: true}, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + _, err := s.Repos.APITokenRepo.Create(ctx, &biz.APITokenCreateOpts{ + Name: randomName(), OrganizationID: tc.org, ProjectID: tc.projectID, + Scope: tc.scope, ScopeID: tc.scopeID, Policies: []*authz.Policy{}, + }) + if !tc.wantErr { + s.NoError(err) + return + } + + s.Require().Error(err) + s.True(biz.IsErrValidation(err), "want a validation error, got %v", err) + s.False(biz.IsErrAlreadyExists(err), "a malformed scope is not a name clash") + }) + } +} + +// Names live in one namespace per product, apart from the organization's own. +func (s *apiTokenTestSuite) TestRepoScopedTokenNameUniqueness() { + ctx := context.Background() + orgUUID := uuid.MustParse(s.org.ID) + productA, productB := uuid.New(), uuid.New() + + scoped := func(name string, productID uuid.UUID) error { + _, err := s.Repos.APITokenRepo.Create(ctx, &biz.APITokenCreateOpts{ + Name: name, OrganizationID: &orgUUID, + Scope: biz.ToPtr(authz.ResourceTypeProduct), ScopeID: &productID, + Policies: []*authz.Policy{}, + }) + return err + } + + s.Require().NoError(scoped("ci", productA)) + s.Require().NoError(scoped("ci", productB), "the same name in a different product is allowed") + s.Error(scoped("ci", productA), "the same name in the same product is refused") + + // An organization-level token may still take that name, and stays unique among its own. + _, err := s.APIToken.Create(ctx, "ci", nil, nil, &s.org.ID) + s.Require().NoError(err) + _, err = s.APIToken.Create(ctx, "ci", nil, nil, &s.org.ID) + s.Error(err) + s.True(biz.IsErrAlreadyExists(err)) +} + +// Organization tokens from before this change carry no scope, new ones carry an organization +// scope. They are the same kind of token, so they share one name namespace. +func (s *apiTokenTestSuite) TestOrgTokenNamesStayUniqueAcrossOldAndNewRows() { + ctx := context.Background() + orgUUID := uuid.MustParse(s.org.ID) + + preChange := func(name string) error { + _, err := s.Repos.APITokenRepo.Create(ctx, &biz.APITokenCreateOpts{ + Name: name, OrganizationID: &orgUUID, Policies: []*authz.Policy{}, + }) + return err + } + + s.Require().NoError(preChange("deploy")) + _, err := s.APIToken.Create(ctx, "deploy", nil, nil, &s.org.ID) + s.Require().Error(err, "a new token cannot take the name of an existing one") + s.True(biz.IsErrAlreadyExists(err)) + + _, err = s.APIToken.Create(ctx, "release", nil, nil, &s.org.ID) + s.Require().NoError(err) + err = preChange("release") + s.Require().Error(err, "a token written the old way cannot take the name of a new one") + s.True(biz.IsErrAlreadyExists(err)) +} + +// Global means confined to neither a project nor a product: organization tokens from before +// and after this change appear under it, product tokens never do. +func (s *apiTokenTestSuite) TestListByScopeSeparatesProductFromGlobal() { + ctx := context.Background() + orgUUID := uuid.MustParse(s.org.ID) + productID := uuid.New() + + productTokenName := randomName() + _, err := s.Repos.APITokenRepo.Create(ctx, &biz.APITokenCreateOpts{ + Name: productTokenName, OrganizationID: &orgUUID, + Scope: biz.ToPtr(authz.ResourceTypeProduct), ScopeID: &productID, + Policies: []*authz.Policy{}, + }) + s.Require().NoError(err) + + preChangeName := randomName() + _, err = s.Repos.APITokenRepo.Create(ctx, &biz.APITokenCreateOpts{ + Name: preChangeName, OrganizationID: &orgUUID, Policies: []*authz.Policy{}, + }) + s.Require().NoError(err) + + global, err := s.APIToken.List(ctx, s.org.ID, biz.WithAPITokenScope(authz.ResourceTypeOrganization)) + s.Require().NoError(err) + + names := make([]string, 0, len(global)) + for _, t := range global { + names = append(names, t.Name) + s.Nil(t.ProjectID) + if t.Scope != nil { + s.NotEqual(authz.ResourceTypeProduct, *t.Scope, "a product token must not appear under the global scope") + } + } + s.Contains(names, preChangeName, "an organization token from before this change") + s.Contains(names, s.t1.Name, "an organization token minted with a scope") + s.NotContains(names, productTokenName) + + products, err := s.APIToken.List(ctx, s.org.ID, biz.WithAPITokenScope(authz.ResourceTypeProduct)) + s.Require().NoError(err) + s.Require().Len(products, 1) + s.Equal(productTokenName, products[0].Name) + + projects, err := s.APIToken.List(ctx, s.org.ID, biz.WithAPITokenScope(authz.ResourceTypeProject)) + s.Require().NoError(err) + s.Len(projects, 3, "the project listing is unchanged") +} diff --git a/app/controlplane/pkg/biz/apitoken_validate_scope_test.go b/app/controlplane/pkg/biz/apitoken_validate_scope_test.go new file mode 100644 index 000000000..e00cdabe5 --- /dev/null +++ b/app/controlplane/pkg/biz/apitoken_validate_scope_test.go @@ -0,0 +1,72 @@ +// +// 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 biz + +import ( + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +// The database refuses most incoherent scopes too; this pins the check Create makes itself, +// before anything is written. +func TestValidateTokenScope(t *testing.T) { + t.Parallel() + + org, otherOrg, project, otherProject, product := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New() + + testCases := []struct { + name string + scope authz.ResourceType + scopeID *uuid.UUID + orgID *uuid.UUID + projectID *uuid.UUID + wantErr bool + }{ + {name: "organization naming its organization", scope: authz.ResourceTypeOrganization, scopeID: &org, orgID: &org}, + {name: "project naming its project", scope: authz.ResourceTypeProject, scopeID: &project, orgID: &org, projectID: &project}, + {name: "instance with no id", scope: authz.ResourceTypeInstance}, + + {name: "organization naming another organization", scope: authz.ResourceTypeOrganization, scopeID: &otherOrg, orgID: &org, wantErr: true}, + {name: "organization with no id", scope: authz.ResourceTypeOrganization, orgID: &org, wantErr: true}, + {name: "organization on a project token", scope: authz.ResourceTypeOrganization, scopeID: &org, orgID: &org, projectID: &project, wantErr: true}, + {name: "organization on an instance-level token", scope: authz.ResourceTypeOrganization, scopeID: &org, wantErr: true}, + {name: "project without its project", scope: authz.ResourceTypeProject, scopeID: &project, orgID: &org, wantErr: true}, + {name: "project naming another project", scope: authz.ResourceTypeProject, scopeID: &otherProject, orgID: &org, projectID: &project, wantErr: true}, + {name: "project with no id", scope: authz.ResourceTypeProject, orgID: &org, projectID: &project, wantErr: true}, + {name: "instance with an id", scope: authz.ResourceTypeInstance, scopeID: &product, wantErr: true}, + {name: "instance on an organization token", scope: authz.ResourceTypeInstance, orgID: &org, wantErr: true}, + {name: "product is not supported yet", scope: authz.ResourceTypeProduct, scopeID: &product, orgID: &org, wantErr: true}, + {name: "a kind tokens are never scoped to", scope: authz.ResourceTypeGroup, scopeID: &product, orgID: &org, wantErr: true}, + {name: "no kind at all", scope: "", orgID: &org, wantErr: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := validateTokenScope(tc.scope, tc.scopeID, tc.orgID, tc.projectID) + if !tc.wantErr { + assert.NoError(t, err) + return + } + + assert.True(t, IsErrValidation(err), "want a validation error, got %v", err) + }) + } +} diff --git a/app/controlplane/pkg/biz/mocks/APITokenRepo.go b/app/controlplane/pkg/biz/mocks/APITokenRepo.go index 56fb12fb2..e3acbd1d3 100644 --- a/app/controlplane/pkg/biz/mocks/APITokenRepo.go +++ b/app/controlplane/pkg/biz/mocks/APITokenRepo.go @@ -8,7 +8,6 @@ import ( "context" "time" - "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/google/uuid" mock "github.com/stretchr/testify/mock" @@ -42,8 +41,8 @@ func (_m *APITokenRepo) EXPECT() *APITokenRepo_Expecter { } // Create provides a mock function for the type APITokenRepo -func (_mock *APITokenRepo) Create(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, workflowID *uuid.UUID, policies []*authz.Policy, isSystem bool) (*biz.APIToken, error) { - ret := _mock.Called(ctx, name, description, expiresAt, organizationID, projectID, workflowID, policies, isSystem) +func (_mock *APITokenRepo) Create(ctx context.Context, opts *biz.APITokenCreateOpts) (*biz.APIToken, error) { + ret := _mock.Called(ctx, opts) if len(ret) == 0 { panic("no return value specified for Create") @@ -51,18 +50,18 @@ func (_mock *APITokenRepo) Create(ctx context.Context, name string, description var r0 *biz.APIToken var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, *string, *time.Time, *uuid.UUID, *uuid.UUID, *uuid.UUID, []*authz.Policy, bool) (*biz.APIToken, error)); ok { - return returnFunc(ctx, name, description, expiresAt, organizationID, projectID, workflowID, policies, isSystem) + if returnFunc, ok := ret.Get(0).(func(context.Context, *biz.APITokenCreateOpts) (*biz.APIToken, error)); ok { + return returnFunc(ctx, opts) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, *string, *time.Time, *uuid.UUID, *uuid.UUID, *uuid.UUID, []*authz.Policy, bool) *biz.APIToken); ok { - r0 = returnFunc(ctx, name, description, expiresAt, organizationID, projectID, workflowID, policies, isSystem) + if returnFunc, ok := ret.Get(0).(func(context.Context, *biz.APITokenCreateOpts) *biz.APIToken); ok { + r0 = returnFunc(ctx, opts) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*biz.APIToken) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, *string, *time.Time, *uuid.UUID, *uuid.UUID, *uuid.UUID, []*authz.Policy, bool) error); ok { - r1 = returnFunc(ctx, name, description, expiresAt, organizationID, projectID, workflowID, policies, isSystem) + if returnFunc, ok := ret.Get(1).(func(context.Context, *biz.APITokenCreateOpts) error); ok { + r1 = returnFunc(ctx, opts) } else { r1 = ret.Error(1) } @@ -76,66 +75,24 @@ type APITokenRepo_Create_Call struct { // Create is a helper method to define mock.On call // - ctx context.Context -// - name string -// - description *string -// - expiresAt *time.Time -// - organizationID *uuid.UUID -// - projectID *uuid.UUID -// - workflowID *uuid.UUID -// - policies []*authz.Policy -// - isSystem bool -func (_e *APITokenRepo_Expecter) Create(ctx interface{}, name interface{}, description interface{}, expiresAt interface{}, organizationID interface{}, projectID interface{}, workflowID interface{}, policies interface{}, isSystem interface{}) *APITokenRepo_Create_Call { - return &APITokenRepo_Create_Call{Call: _e.mock.On("Create", ctx, name, description, expiresAt, organizationID, projectID, workflowID, policies, isSystem)} -} - -func (_c *APITokenRepo_Create_Call) Run(run func(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, workflowID *uuid.UUID, policies []*authz.Policy, isSystem bool)) *APITokenRepo_Create_Call { +// - opts *biz.APITokenCreateOpts +func (_e *APITokenRepo_Expecter) Create(ctx any, opts any) *APITokenRepo_Create_Call { + return &APITokenRepo_Create_Call{Call: _e.mock.On("Create", ctx, opts)} +} + +func (_c *APITokenRepo_Create_Call) Run(run func(ctx context.Context, opts *biz.APITokenCreateOpts)) *APITokenRepo_Create_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } - var arg1 string + var arg1 *biz.APITokenCreateOpts if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 *string - if args[2] != nil { - arg2 = args[2].(*string) - } - var arg3 *time.Time - if args[3] != nil { - arg3 = args[3].(*time.Time) - } - var arg4 *uuid.UUID - if args[4] != nil { - arg4 = args[4].(*uuid.UUID) - } - var arg5 *uuid.UUID - if args[5] != nil { - arg5 = args[5].(*uuid.UUID) - } - var arg6 *uuid.UUID - if args[6] != nil { - arg6 = args[6].(*uuid.UUID) - } - var arg7 []*authz.Policy - if args[7] != nil { - arg7 = args[7].([]*authz.Policy) - } - var arg8 bool - if args[8] != nil { - arg8 = args[8].(bool) + arg1 = args[1].(*biz.APITokenCreateOpts) } run( arg0, arg1, - arg2, - arg3, - arg4, - arg5, - arg6, - arg7, - arg8, ) }) return _c @@ -146,7 +103,7 @@ func (_c *APITokenRepo_Create_Call) Return(aPIToken *biz.APIToken, err error) *A return _c } -func (_c *APITokenRepo_Create_Call) RunAndReturn(run func(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, workflowID *uuid.UUID, policies []*authz.Policy, isSystem bool) (*biz.APIToken, error)) *APITokenRepo_Create_Call { +func (_c *APITokenRepo_Create_Call) RunAndReturn(run func(ctx context.Context, opts *biz.APITokenCreateOpts) (*biz.APIToken, error)) *APITokenRepo_Create_Call { _c.Call.Return(run) return _c } @@ -187,7 +144,7 @@ type APITokenRepo_FindByID_Call struct { // FindByID is a helper method to define mock.On call // - ctx context.Context // - ID uuid.UUID -func (_e *APITokenRepo_Expecter) FindByID(ctx interface{}, ID interface{}) *APITokenRepo_FindByID_Call { +func (_e *APITokenRepo_Expecter) FindByID(ctx any, ID any) *APITokenRepo_FindByID_Call { return &APITokenRepo_FindByID_Call{Call: _e.mock.On("FindByID", ctx, ID)} } @@ -256,7 +213,7 @@ type APITokenRepo_FindByIDInOrg_Call struct { // - ctx context.Context // - orgID uuid.UUID // - id uuid.UUID -func (_e *APITokenRepo_Expecter) FindByIDInOrg(ctx interface{}, orgID interface{}, id interface{}) *APITokenRepo_FindByIDInOrg_Call { +func (_e *APITokenRepo_Expecter) FindByIDInOrg(ctx any, orgID any, id any) *APITokenRepo_FindByIDInOrg_Call { return &APITokenRepo_FindByIDInOrg_Call{Call: _e.mock.On("FindByIDInOrg", ctx, orgID, id)} } @@ -330,7 +287,7 @@ type APITokenRepo_FindByNameInOrg_Call struct { // - ctx context.Context // - orgID uuid.UUID // - name string -func (_e *APITokenRepo_Expecter) FindByNameInOrg(ctx interface{}, orgID interface{}, name interface{}) *APITokenRepo_FindByNameInOrg_Call { +func (_e *APITokenRepo_Expecter) FindByNameInOrg(ctx any, orgID any, name any) *APITokenRepo_FindByNameInOrg_Call { return &APITokenRepo_FindByNameInOrg_Call{Call: _e.mock.On("FindByNameInOrg", ctx, orgID, name)} } @@ -404,7 +361,7 @@ type APITokenRepo_FindInactive_Call struct { // - ctx context.Context // - orgID uuid.UUID // - inactiveSince time.Time -func (_e *APITokenRepo_Expecter) FindInactive(ctx interface{}, orgID interface{}, inactiveSince interface{}) *APITokenRepo_FindInactive_Call { +func (_e *APITokenRepo_Expecter) FindInactive(ctx any, orgID any, inactiveSince any) *APITokenRepo_FindInactive_Call { return &APITokenRepo_FindInactive_Call{Call: _e.mock.On("FindInactive", ctx, orgID, inactiveSince)} } @@ -478,7 +435,7 @@ type APITokenRepo_List_Call struct { // - ctx context.Context // - orgID *uuid.UUID // - filters *biz.APITokenListFilters -func (_e *APITokenRepo_Expecter) List(ctx interface{}, orgID interface{}, filters interface{}) *APITokenRepo_List_Call { +func (_e *APITokenRepo_Expecter) List(ctx any, orgID any, filters any) *APITokenRepo_List_Call { return &APITokenRepo_List_Call{Call: _e.mock.On("List", ctx, orgID, filters)} } @@ -541,7 +498,7 @@ type APITokenRepo_Revoke_Call struct { // - ctx context.Context // - orgID *uuid.UUID // - ID uuid.UUID -func (_e *APITokenRepo_Expecter) Revoke(ctx interface{}, orgID interface{}, ID interface{}) *APITokenRepo_Revoke_Call { +func (_e *APITokenRepo_Expecter) Revoke(ctx any, orgID any, ID any) *APITokenRepo_Revoke_Call { return &APITokenRepo_Revoke_Call{Call: _e.mock.On("Revoke", ctx, orgID, ID)} } @@ -604,7 +561,7 @@ type APITokenRepo_UpdateExpiration_Call struct { // - ctx context.Context // - ID uuid.UUID // - expiresAt time.Time -func (_e *APITokenRepo_Expecter) UpdateExpiration(ctx interface{}, ID interface{}, expiresAt interface{}) *APITokenRepo_UpdateExpiration_Call { +func (_e *APITokenRepo_Expecter) UpdateExpiration(ctx any, ID any, expiresAt any) *APITokenRepo_UpdateExpiration_Call { return &APITokenRepo_UpdateExpiration_Call{Call: _e.mock.On("UpdateExpiration", ctx, ID, expiresAt)} } @@ -667,7 +624,7 @@ type APITokenRepo_UpdateLastUsedAt_Call struct { // - ctx context.Context // - ID uuid.UUID // - lastUsedAt time.Time -func (_e *APITokenRepo_Expecter) UpdateLastUsedAt(ctx interface{}, ID interface{}, lastUsedAt interface{}) *APITokenRepo_UpdateLastUsedAt_Call { +func (_e *APITokenRepo_Expecter) UpdateLastUsedAt(ctx any, ID any, lastUsedAt any) *APITokenRepo_UpdateLastUsedAt_Call { return &APITokenRepo_UpdateLastUsedAt_Call{Call: _e.mock.On("UpdateLastUsedAt", ctx, ID, lastUsedAt)} } diff --git a/app/controlplane/pkg/data/apitoken.go b/app/controlplane/pkg/data/apitoken.go index ad8267d8b..4b63fe7af 100644 --- a/app/controlplane/pkg/data/apitoken.go +++ b/app/controlplane/pkg/data/apitoken.go @@ -20,6 +20,7 @@ import ( "fmt" "time" + "entgo.io/ent/dialect/sql/sqlgraph" "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/data/ent" @@ -45,21 +46,28 @@ func NewAPITokenRepo(data *Data, logger log.Logger) biz.APITokenRepo { } // Persist the APIToken to the database. -func (r *APITokenRepo) Create(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, workflowID *uuid.UUID, policies []*authz.Policy, isSystem bool) (*biz.APIToken, error) { +func (r *APITokenRepo) Create(ctx context.Context, opts *biz.APITokenCreateOpts) (*biz.APIToken, error) { ctx, span := otelx.Start(ctx, apiTokenRepoTracer, "APITokenRepo.Create") defer span.End() token, err := r.data.DB.APIToken.Create(). - SetName(name). - SetNillableDescription(description). - SetNillableExpiresAt(expiresAt). - SetNillableOrganizationID(organizationID). - SetNillableProjectID(projectID). - SetNillableWorkflowID(workflowID). - SetPolicies(policies). - SetIsSystem(isSystem). + SetName(opts.Name). + SetNillableDescription(opts.Description). + SetNillableExpiresAt(opts.ExpiresAt). + SetNillableOrganizationID(opts.OrganizationID). + SetNillableProjectID(opts.ProjectID). + SetNillableWorkflowID(opts.WorkflowID). + SetNillableScope(opts.Scope). + SetNillableScopeID(opts.ScopeID). + SetPolicies(opts.Policies). + SetIsSystem(opts.IsSystem). Save(ctx) if err != nil { + // A CHECK violation is a malformed scope, not a name clash. + if sqlgraph.IsCheckConstraintError(err) { + return nil, biz.NewErrValidation(err) + } + if ent.IsConstraintError(err) { return nil, biz.NewErrAlreadyExists(err) } @@ -139,11 +147,15 @@ func (r *APITokenRepo) List(ctx context.Context, orgID *uuid.UUID, filters *biz. } switch filters.FilterByScope { - case biz.APITokenScopeProject: + case authz.ResourceTypeProject: query = query.Where(apitoken.ProjectIDNotNil()) - case biz.APITokenScopeGlobal: - query = query.Where(apitoken.ProjectIDIsNil()) - case biz.APITokenScopeInstance: + case authz.ResourceTypeProduct: + query = query.Where(apitoken.ScopeEQ(authz.ResourceTypeProduct)) + case authz.ResourceTypeOrganization: + // Organization-wide means confined to neither a project nor a product. Keyed on the + // kind: new organization tokens carry an organization scope, older ones none. + query = query.Where(apitoken.ProjectIDIsNil(), apitoken.Or(apitoken.ScopeIsNil(), apitoken.ScopeNEQ(authz.ResourceTypeProduct))) + case authz.ResourceTypeInstance: query = query.Where(apitoken.OrganizationIDIsNil()) } @@ -282,5 +294,10 @@ func entAPITokenToBiz(t *ent.APIToken) *biz.APIToken { result.WorkflowName = biz.ToPtr(w.Name) } + // The scoped resource is not an entity in this database, so unlike the project and the + // workflow it has no edge to load: both values come straight off the row. + result.Scope = t.Scope + result.ScopeID = t.ScopeID + return result } diff --git a/app/controlplane/pkg/data/ent/apitoken.go b/app/controlplane/pkg/data/ent/apitoken.go index 5c63e8fc2..8195b8fdc 100644 --- a/app/controlplane/pkg/data/ent/apitoken.go +++ b/app/controlplane/pkg/data/ent/apitoken.go @@ -41,6 +41,10 @@ type APIToken struct { ProjectID uuid.UUID `json:"project_id,omitempty"` // WorkflowID holds the value of the "workflow_id" field. WorkflowID uuid.UUID `json:"workflow_id,omitempty"` + // Scope holds the value of the "scope" field. + Scope *authz.ResourceType `json:"scope,omitempty"` + // ScopeID holds the value of the "scope_id" field. + ScopeID *uuid.UUID `json:"scope_id,omitempty"` // Policies holds the value of the "policies" field. Policies []*authz.Policy `json:"policies,omitempty"` // IsSystem holds the value of the "is_system" field. @@ -102,11 +106,13 @@ func (*APIToken) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { + case apitoken.FieldScopeID: + values[i] = &sql.NullScanner{S: new(uuid.UUID)} case apitoken.FieldPolicies: values[i] = new([]byte) case apitoken.FieldIsSystem: values[i] = new(sql.NullBool) - case apitoken.FieldName, apitoken.FieldDescription: + case apitoken.FieldName, apitoken.FieldDescription, apitoken.FieldScope: values[i] = new(sql.NullString) case apitoken.FieldCreatedAt, apitoken.FieldExpiresAt, apitoken.FieldRevokedAt, apitoken.FieldLastUsedAt: values[i] = new(sql.NullTime) @@ -187,6 +193,20 @@ func (_m *APIToken) assignValues(columns []string, values []any) error { } else if value != nil { _m.WorkflowID = *value } + case apitoken.FieldScope: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field scope", values[i]) + } else if value.Valid { + _m.Scope = new(authz.ResourceType) + *_m.Scope = authz.ResourceType(value.String) + } + case apitoken.FieldScopeID: + if value, ok := values[i].(*sql.NullScanner); !ok { + return fmt.Errorf("unexpected type %T for field scope_id", values[i]) + } else if value.Valid { + _m.ScopeID = new(uuid.UUID) + *_m.ScopeID = *value.S.(*uuid.UUID) + } case apitoken.FieldPolicies: if value, ok := values[i].(*[]byte); !ok { return fmt.Errorf("unexpected type %T for field policies", values[i]) @@ -279,6 +299,16 @@ func (_m *APIToken) String() string { builder.WriteString("workflow_id=") builder.WriteString(fmt.Sprintf("%v", _m.WorkflowID)) builder.WriteString(", ") + if v := _m.Scope; v != nil { + builder.WriteString("scope=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + if v := _m.ScopeID; v != nil { + builder.WriteString("scope_id=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") builder.WriteString("policies=") builder.WriteString(fmt.Sprintf("%v", _m.Policies)) builder.WriteString(", ") diff --git a/app/controlplane/pkg/data/ent/apitoken/apitoken.go b/app/controlplane/pkg/data/ent/apitoken/apitoken.go index 97d140169..bc7dfda11 100644 --- a/app/controlplane/pkg/data/ent/apitoken/apitoken.go +++ b/app/controlplane/pkg/data/ent/apitoken/apitoken.go @@ -3,10 +3,12 @@ package apitoken import ( + "fmt" "time" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/google/uuid" ) @@ -33,6 +35,10 @@ const ( FieldProjectID = "project_id" // FieldWorkflowID holds the string denoting the workflow_id field in the database. FieldWorkflowID = "workflow_id" + // FieldScope holds the string denoting the scope field in the database. + FieldScope = "scope" + // FieldScopeID holds the string denoting the scope_id field in the database. + FieldScopeID = "scope_id" // FieldPolicies holds the string denoting the policies field in the database. FieldPolicies = "policies" // FieldIsSystem holds the string denoting the is_system field in the database. @@ -80,6 +86,8 @@ var Columns = []string{ FieldOrganizationID, FieldProjectID, FieldWorkflowID, + FieldScope, + FieldScopeID, FieldPolicies, FieldIsSystem, } @@ -103,6 +111,16 @@ var ( DefaultID func() uuid.UUID ) +// ScopeValidator is a validator for the "scope" field enum values. It is called by the builders before save. +func ScopeValidator(s authz.ResourceType) error { + switch s { + case "instance", "organization", "project", "group", "product": + return nil + default: + return fmt.Errorf("apitoken: invalid enum value for scope field: %q", s) + } +} + // OrderOption defines the ordering options for the APIToken queries. type OrderOption func(*sql.Selector) @@ -156,6 +174,16 @@ func ByWorkflowID(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldWorkflowID, opts...).ToFunc() } +// ByScope orders the results by the scope field. +func ByScope(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldScope, opts...).ToFunc() +} + +// ByScopeID orders the results by the scope_id field. +func ByScopeID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldScopeID, opts...).ToFunc() +} + // ByIsSystem orders the results by the is_system field. func ByIsSystem(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldIsSystem, opts...).ToFunc() diff --git a/app/controlplane/pkg/data/ent/apitoken/where.go b/app/controlplane/pkg/data/ent/apitoken/where.go index 6faa3b3d0..b109ec3f4 100644 --- a/app/controlplane/pkg/data/ent/apitoken/where.go +++ b/app/controlplane/pkg/data/ent/apitoken/where.go @@ -7,6 +7,7 @@ import ( "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/predicate" "github.com/google/uuid" ) @@ -101,6 +102,11 @@ func WorkflowID(v uuid.UUID) predicate.APIToken { return predicate.APIToken(sql.FieldEQ(FieldWorkflowID, v)) } +// ScopeID applies equality check predicate on the "scope_id" field. It's identical to ScopeIDEQ. +func ScopeID(v uuid.UUID) predicate.APIToken { + return predicate.APIToken(sql.FieldEQ(FieldScopeID, v)) +} + // IsSystem applies equality check predicate on the "is_system" field. It's identical to IsSystemEQ. func IsSystem(v bool) predicate.APIToken { return predicate.APIToken(sql.FieldEQ(FieldIsSystem, v)) @@ -526,6 +532,96 @@ func WorkflowIDNotNil() predicate.APIToken { return predicate.APIToken(sql.FieldNotNull(FieldWorkflowID)) } +// ScopeEQ applies the EQ predicate on the "scope" field. +func ScopeEQ(v authz.ResourceType) predicate.APIToken { + vc := v + return predicate.APIToken(sql.FieldEQ(FieldScope, vc)) +} + +// ScopeNEQ applies the NEQ predicate on the "scope" field. +func ScopeNEQ(v authz.ResourceType) predicate.APIToken { + vc := v + return predicate.APIToken(sql.FieldNEQ(FieldScope, vc)) +} + +// ScopeIn applies the In predicate on the "scope" field. +func ScopeIn(vs ...authz.ResourceType) predicate.APIToken { + v := make([]any, len(vs)) + for i := range v { + v[i] = vs[i] + } + return predicate.APIToken(sql.FieldIn(FieldScope, v...)) +} + +// ScopeNotIn applies the NotIn predicate on the "scope" field. +func ScopeNotIn(vs ...authz.ResourceType) predicate.APIToken { + v := make([]any, len(vs)) + for i := range v { + v[i] = vs[i] + } + return predicate.APIToken(sql.FieldNotIn(FieldScope, v...)) +} + +// ScopeIsNil applies the IsNil predicate on the "scope" field. +func ScopeIsNil() predicate.APIToken { + return predicate.APIToken(sql.FieldIsNull(FieldScope)) +} + +// ScopeNotNil applies the NotNil predicate on the "scope" field. +func ScopeNotNil() predicate.APIToken { + return predicate.APIToken(sql.FieldNotNull(FieldScope)) +} + +// ScopeIDEQ applies the EQ predicate on the "scope_id" field. +func ScopeIDEQ(v uuid.UUID) predicate.APIToken { + return predicate.APIToken(sql.FieldEQ(FieldScopeID, v)) +} + +// ScopeIDNEQ applies the NEQ predicate on the "scope_id" field. +func ScopeIDNEQ(v uuid.UUID) predicate.APIToken { + return predicate.APIToken(sql.FieldNEQ(FieldScopeID, v)) +} + +// ScopeIDIn applies the In predicate on the "scope_id" field. +func ScopeIDIn(vs ...uuid.UUID) predicate.APIToken { + return predicate.APIToken(sql.FieldIn(FieldScopeID, vs...)) +} + +// ScopeIDNotIn applies the NotIn predicate on the "scope_id" field. +func ScopeIDNotIn(vs ...uuid.UUID) predicate.APIToken { + return predicate.APIToken(sql.FieldNotIn(FieldScopeID, vs...)) +} + +// ScopeIDGT applies the GT predicate on the "scope_id" field. +func ScopeIDGT(v uuid.UUID) predicate.APIToken { + return predicate.APIToken(sql.FieldGT(FieldScopeID, v)) +} + +// ScopeIDGTE applies the GTE predicate on the "scope_id" field. +func ScopeIDGTE(v uuid.UUID) predicate.APIToken { + return predicate.APIToken(sql.FieldGTE(FieldScopeID, v)) +} + +// ScopeIDLT applies the LT predicate on the "scope_id" field. +func ScopeIDLT(v uuid.UUID) predicate.APIToken { + return predicate.APIToken(sql.FieldLT(FieldScopeID, v)) +} + +// ScopeIDLTE applies the LTE predicate on the "scope_id" field. +func ScopeIDLTE(v uuid.UUID) predicate.APIToken { + return predicate.APIToken(sql.FieldLTE(FieldScopeID, v)) +} + +// ScopeIDIsNil applies the IsNil predicate on the "scope_id" field. +func ScopeIDIsNil() predicate.APIToken { + return predicate.APIToken(sql.FieldIsNull(FieldScopeID)) +} + +// ScopeIDNotNil applies the NotNil predicate on the "scope_id" field. +func ScopeIDNotNil() predicate.APIToken { + return predicate.APIToken(sql.FieldNotNull(FieldScopeID)) +} + // PoliciesIsNil applies the IsNil predicate on the "policies" field. func PoliciesIsNil() predicate.APIToken { return predicate.APIToken(sql.FieldIsNull(FieldPolicies)) diff --git a/app/controlplane/pkg/data/ent/apitoken_create.go b/app/controlplane/pkg/data/ent/apitoken_create.go index 169f6bd1d..fb35f63ad 100644 --- a/app/controlplane/pkg/data/ent/apitoken_create.go +++ b/app/controlplane/pkg/data/ent/apitoken_create.go @@ -146,6 +146,34 @@ func (_c *APITokenCreate) SetNillableWorkflowID(v *uuid.UUID) *APITokenCreate { return _c } +// SetScope sets the "scope" field. +func (_c *APITokenCreate) SetScope(v authz.ResourceType) *APITokenCreate { + _c.mutation.SetScope(v) + return _c +} + +// SetNillableScope sets the "scope" field if the given value is not nil. +func (_c *APITokenCreate) SetNillableScope(v *authz.ResourceType) *APITokenCreate { + if v != nil { + _c.SetScope(*v) + } + return _c +} + +// SetScopeID sets the "scope_id" field. +func (_c *APITokenCreate) SetScopeID(v uuid.UUID) *APITokenCreate { + _c.mutation.SetScopeID(v) + return _c +} + +// SetNillableScopeID sets the "scope_id" field if the given value is not nil. +func (_c *APITokenCreate) SetNillableScopeID(v *uuid.UUID) *APITokenCreate { + if v != nil { + _c.SetScopeID(*v) + } + return _c +} + // SetPolicies sets the "policies" field. func (_c *APITokenCreate) SetPolicies(v []*authz.Policy) *APITokenCreate { _c.mutation.SetPolicies(v) @@ -252,6 +280,11 @@ func (_c *APITokenCreate) check() error { if _, ok := _c.mutation.CreatedAt(); !ok { return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "APIToken.created_at"`)} } + if v, ok := _c.mutation.Scope(); ok { + if err := apitoken.ScopeValidator(v); err != nil { + return &ValidationError{Name: "scope", err: fmt.Errorf(`ent: validator failed for field "APIToken.scope": %w`, err)} + } + } if _, ok := _c.mutation.IsSystem(); !ok { return &ValidationError{Name: "is_system", err: errors.New(`ent: missing required field "APIToken.is_system"`)} } @@ -315,6 +348,14 @@ func (_c *APITokenCreate) createSpec() (*APIToken, *sqlgraph.CreateSpec) { _spec.SetField(apitoken.FieldLastUsedAt, field.TypeTime, value) _node.LastUsedAt = value } + if value, ok := _c.mutation.Scope(); ok { + _spec.SetField(apitoken.FieldScope, field.TypeEnum, value) + _node.Scope = &value + } + if value, ok := _c.mutation.ScopeID(); ok { + _spec.SetField(apitoken.FieldScopeID, field.TypeUUID, value) + _node.ScopeID = &value + } if value, ok := _c.mutation.Policies(); ok { _spec.SetField(apitoken.FieldPolicies, field.TypeJSON, value) _node.Policies = value @@ -552,6 +593,42 @@ func (u *APITokenUpsert) ClearWorkflowID() *APITokenUpsert { return u } +// SetScope sets the "scope" field. +func (u *APITokenUpsert) SetScope(v authz.ResourceType) *APITokenUpsert { + u.Set(apitoken.FieldScope, v) + return u +} + +// UpdateScope sets the "scope" field to the value that was provided on create. +func (u *APITokenUpsert) UpdateScope() *APITokenUpsert { + u.SetExcluded(apitoken.FieldScope) + return u +} + +// ClearScope clears the value of the "scope" field. +func (u *APITokenUpsert) ClearScope() *APITokenUpsert { + u.SetNull(apitoken.FieldScope) + return u +} + +// SetScopeID sets the "scope_id" field. +func (u *APITokenUpsert) SetScopeID(v uuid.UUID) *APITokenUpsert { + u.Set(apitoken.FieldScopeID, v) + return u +} + +// UpdateScopeID sets the "scope_id" field to the value that was provided on create. +func (u *APITokenUpsert) UpdateScopeID() *APITokenUpsert { + u.SetExcluded(apitoken.FieldScopeID) + return u +} + +// ClearScopeID clears the value of the "scope_id" field. +func (u *APITokenUpsert) ClearScopeID() *APITokenUpsert { + u.SetNull(apitoken.FieldScopeID) + return u +} + // SetPolicies sets the "policies" field. func (u *APITokenUpsert) SetPolicies(v []*authz.Policy) *APITokenUpsert { u.Set(apitoken.FieldPolicies, v) @@ -774,6 +851,48 @@ func (u *APITokenUpsertOne) ClearWorkflowID() *APITokenUpsertOne { }) } +// SetScope sets the "scope" field. +func (u *APITokenUpsertOne) SetScope(v authz.ResourceType) *APITokenUpsertOne { + return u.Update(func(s *APITokenUpsert) { + s.SetScope(v) + }) +} + +// UpdateScope sets the "scope" field to the value that was provided on create. +func (u *APITokenUpsertOne) UpdateScope() *APITokenUpsertOne { + return u.Update(func(s *APITokenUpsert) { + s.UpdateScope() + }) +} + +// ClearScope clears the value of the "scope" field. +func (u *APITokenUpsertOne) ClearScope() *APITokenUpsertOne { + return u.Update(func(s *APITokenUpsert) { + s.ClearScope() + }) +} + +// SetScopeID sets the "scope_id" field. +func (u *APITokenUpsertOne) SetScopeID(v uuid.UUID) *APITokenUpsertOne { + return u.Update(func(s *APITokenUpsert) { + s.SetScopeID(v) + }) +} + +// UpdateScopeID sets the "scope_id" field to the value that was provided on create. +func (u *APITokenUpsertOne) UpdateScopeID() *APITokenUpsertOne { + return u.Update(func(s *APITokenUpsert) { + s.UpdateScopeID() + }) +} + +// ClearScopeID clears the value of the "scope_id" field. +func (u *APITokenUpsertOne) ClearScopeID() *APITokenUpsertOne { + return u.Update(func(s *APITokenUpsert) { + s.ClearScopeID() + }) +} + // SetPolicies sets the "policies" field. func (u *APITokenUpsertOne) SetPolicies(v []*authz.Policy) *APITokenUpsertOne { return u.Update(func(s *APITokenUpsert) { @@ -1166,6 +1285,48 @@ func (u *APITokenUpsertBulk) ClearWorkflowID() *APITokenUpsertBulk { }) } +// SetScope sets the "scope" field. +func (u *APITokenUpsertBulk) SetScope(v authz.ResourceType) *APITokenUpsertBulk { + return u.Update(func(s *APITokenUpsert) { + s.SetScope(v) + }) +} + +// UpdateScope sets the "scope" field to the value that was provided on create. +func (u *APITokenUpsertBulk) UpdateScope() *APITokenUpsertBulk { + return u.Update(func(s *APITokenUpsert) { + s.UpdateScope() + }) +} + +// ClearScope clears the value of the "scope" field. +func (u *APITokenUpsertBulk) ClearScope() *APITokenUpsertBulk { + return u.Update(func(s *APITokenUpsert) { + s.ClearScope() + }) +} + +// SetScopeID sets the "scope_id" field. +func (u *APITokenUpsertBulk) SetScopeID(v uuid.UUID) *APITokenUpsertBulk { + return u.Update(func(s *APITokenUpsert) { + s.SetScopeID(v) + }) +} + +// UpdateScopeID sets the "scope_id" field to the value that was provided on create. +func (u *APITokenUpsertBulk) UpdateScopeID() *APITokenUpsertBulk { + return u.Update(func(s *APITokenUpsert) { + s.UpdateScopeID() + }) +} + +// ClearScopeID clears the value of the "scope_id" field. +func (u *APITokenUpsertBulk) ClearScopeID() *APITokenUpsertBulk { + return u.Update(func(s *APITokenUpsert) { + s.ClearScopeID() + }) +} + // SetPolicies sets the "policies" field. func (u *APITokenUpsertBulk) SetPolicies(v []*authz.Policy) *APITokenUpsertBulk { return u.Update(func(s *APITokenUpsert) { diff --git a/app/controlplane/pkg/data/ent/apitoken_update.go b/app/controlplane/pkg/data/ent/apitoken_update.go index fafda7352..727099a88 100644 --- a/app/controlplane/pkg/data/ent/apitoken_update.go +++ b/app/controlplane/pkg/data/ent/apitoken_update.go @@ -175,6 +175,46 @@ func (_u *APITokenUpdate) ClearWorkflowID() *APITokenUpdate { return _u } +// SetScope sets the "scope" field. +func (_u *APITokenUpdate) SetScope(v authz.ResourceType) *APITokenUpdate { + _u.mutation.SetScope(v) + return _u +} + +// SetNillableScope sets the "scope" field if the given value is not nil. +func (_u *APITokenUpdate) SetNillableScope(v *authz.ResourceType) *APITokenUpdate { + if v != nil { + _u.SetScope(*v) + } + return _u +} + +// ClearScope clears the value of the "scope" field. +func (_u *APITokenUpdate) ClearScope() *APITokenUpdate { + _u.mutation.ClearScope() + return _u +} + +// SetScopeID sets the "scope_id" field. +func (_u *APITokenUpdate) SetScopeID(v uuid.UUID) *APITokenUpdate { + _u.mutation.SetScopeID(v) + return _u +} + +// SetNillableScopeID sets the "scope_id" field if the given value is not nil. +func (_u *APITokenUpdate) SetNillableScopeID(v *uuid.UUID) *APITokenUpdate { + if v != nil { + _u.SetScopeID(*v) + } + return _u +} + +// ClearScopeID clears the value of the "scope_id" field. +func (_u *APITokenUpdate) ClearScopeID() *APITokenUpdate { + _u.mutation.ClearScopeID() + return _u +} + // SetPolicies sets the "policies" field. func (_u *APITokenUpdate) SetPolicies(v []*authz.Policy) *APITokenUpdate { _u.mutation.SetPolicies(v) @@ -258,6 +298,16 @@ func (_u *APITokenUpdate) ExecX(ctx context.Context) { } } +// check runs all checks and user-defined validators on the builder. +func (_u *APITokenUpdate) check() error { + if v, ok := _u.mutation.Scope(); ok { + if err := apitoken.ScopeValidator(v); err != nil { + return &ValidationError{Name: "scope", err: fmt.Errorf(`ent: validator failed for field "APIToken.scope": %w`, err)} + } + } + return nil +} + // Modify adds a statement modifier for attaching custom logic to the UPDATE statement. func (_u *APITokenUpdate) Modify(modifiers ...func(u *sql.UpdateBuilder)) *APITokenUpdate { _u.modifiers = append(_u.modifiers, modifiers...) @@ -265,6 +315,9 @@ func (_u *APITokenUpdate) Modify(modifiers ...func(u *sql.UpdateBuilder)) *APITo } func (_u *APITokenUpdate) sqlSave(ctx context.Context) (_node int, err error) { + if err := _u.check(); err != nil { + return _node, err + } _spec := sqlgraph.NewUpdateSpec(apitoken.Table, apitoken.Columns, sqlgraph.NewFieldSpec(apitoken.FieldID, field.TypeUUID)) if ps := _u.mutation.predicates; len(ps) > 0 { _spec.Predicate = func(selector *sql.Selector) { @@ -297,6 +350,18 @@ func (_u *APITokenUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.LastUsedAtCleared() { _spec.ClearField(apitoken.FieldLastUsedAt, field.TypeTime) } + if value, ok := _u.mutation.Scope(); ok { + _spec.SetField(apitoken.FieldScope, field.TypeEnum, value) + } + if _u.mutation.ScopeCleared() { + _spec.ClearField(apitoken.FieldScope, field.TypeEnum) + } + if value, ok := _u.mutation.ScopeID(); ok { + _spec.SetField(apitoken.FieldScopeID, field.TypeUUID, value) + } + if _u.mutation.ScopeIDCleared() { + _spec.ClearField(apitoken.FieldScopeID, field.TypeUUID) + } if value, ok := _u.mutation.Policies(); ok { _spec.SetField(apitoken.FieldPolicies, field.TypeJSON, value) } @@ -557,6 +622,46 @@ func (_u *APITokenUpdateOne) ClearWorkflowID() *APITokenUpdateOne { return _u } +// SetScope sets the "scope" field. +func (_u *APITokenUpdateOne) SetScope(v authz.ResourceType) *APITokenUpdateOne { + _u.mutation.SetScope(v) + return _u +} + +// SetNillableScope sets the "scope" field if the given value is not nil. +func (_u *APITokenUpdateOne) SetNillableScope(v *authz.ResourceType) *APITokenUpdateOne { + if v != nil { + _u.SetScope(*v) + } + return _u +} + +// ClearScope clears the value of the "scope" field. +func (_u *APITokenUpdateOne) ClearScope() *APITokenUpdateOne { + _u.mutation.ClearScope() + return _u +} + +// SetScopeID sets the "scope_id" field. +func (_u *APITokenUpdateOne) SetScopeID(v uuid.UUID) *APITokenUpdateOne { + _u.mutation.SetScopeID(v) + return _u +} + +// SetNillableScopeID sets the "scope_id" field if the given value is not nil. +func (_u *APITokenUpdateOne) SetNillableScopeID(v *uuid.UUID) *APITokenUpdateOne { + if v != nil { + _u.SetScopeID(*v) + } + return _u +} + +// ClearScopeID clears the value of the "scope_id" field. +func (_u *APITokenUpdateOne) ClearScopeID() *APITokenUpdateOne { + _u.mutation.ClearScopeID() + return _u +} + // SetPolicies sets the "policies" field. func (_u *APITokenUpdateOne) SetPolicies(v []*authz.Policy) *APITokenUpdateOne { _u.mutation.SetPolicies(v) @@ -653,6 +758,16 @@ func (_u *APITokenUpdateOne) ExecX(ctx context.Context) { } } +// check runs all checks and user-defined validators on the builder. +func (_u *APITokenUpdateOne) check() error { + if v, ok := _u.mutation.Scope(); ok { + if err := apitoken.ScopeValidator(v); err != nil { + return &ValidationError{Name: "scope", err: fmt.Errorf(`ent: validator failed for field "APIToken.scope": %w`, err)} + } + } + return nil +} + // Modify adds a statement modifier for attaching custom logic to the UPDATE statement. func (_u *APITokenUpdateOne) Modify(modifiers ...func(u *sql.UpdateBuilder)) *APITokenUpdateOne { _u.modifiers = append(_u.modifiers, modifiers...) @@ -660,6 +775,9 @@ func (_u *APITokenUpdateOne) Modify(modifiers ...func(u *sql.UpdateBuilder)) *AP } func (_u *APITokenUpdateOne) sqlSave(ctx context.Context) (_node *APIToken, err error) { + if err := _u.check(); err != nil { + return _node, err + } _spec := sqlgraph.NewUpdateSpec(apitoken.Table, apitoken.Columns, sqlgraph.NewFieldSpec(apitoken.FieldID, field.TypeUUID)) id, ok := _u.mutation.ID() if !ok { @@ -709,6 +827,18 @@ func (_u *APITokenUpdateOne) sqlSave(ctx context.Context) (_node *APIToken, err if _u.mutation.LastUsedAtCleared() { _spec.ClearField(apitoken.FieldLastUsedAt, field.TypeTime) } + if value, ok := _u.mutation.Scope(); ok { + _spec.SetField(apitoken.FieldScope, field.TypeEnum, value) + } + if _u.mutation.ScopeCleared() { + _spec.ClearField(apitoken.FieldScope, field.TypeEnum) + } + if value, ok := _u.mutation.ScopeID(); ok { + _spec.SetField(apitoken.FieldScopeID, field.TypeUUID, value) + } + if _u.mutation.ScopeIDCleared() { + _spec.ClearField(apitoken.FieldScopeID, field.TypeUUID) + } if value, ok := _u.mutation.Policies(); ok { _spec.SetField(apitoken.FieldPolicies, field.TypeJSON, value) } diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260929111949.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260929111949.sql new file mode 100644 index 000000000..ae2144ee1 --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260929111949.sql @@ -0,0 +1,11 @@ +-- Transactional on purpose: a concurrent rebuild of a unique index leaves a window with no +-- uniqueness on the organization-level token name, and cannot be retried if interrupted. + +-- Drop index "apitoken_name_organization_id" from table: "api_tokens" +DROP INDEX "apitoken_name_organization_id"; +-- Modify "api_tokens" table +ALTER TABLE "api_tokens" ADD CONSTRAINT "apitoken_scope_id_presence" CHECK ((scope_id IS NOT NULL) = ((scope IS NOT NULL) AND ((scope)::text <> 'instance'::text))), ADD CONSTRAINT "apitoken_scope_matches_token" CHECK ((scope IS NULL) OR (((scope)::text = 'organization'::text) AND (project_id IS NULL) AND (NOT (scope_id IS DISTINCT FROM organization_id))) OR (((scope)::text = 'project'::text) AND (NOT (scope_id IS DISTINCT FROM project_id))) OR (((scope)::text = 'instance'::text) AND (organization_id IS NULL) AND (project_id IS NULL)) OR (((scope)::text = 'product'::text) AND (organization_id IS NOT NULL) AND (project_id IS NULL))), ADD COLUMN "scope" character varying NULL, ADD COLUMN "scope_id" uuid NULL; +-- Create index "apitoken_name_organization_id" to table: "api_tokens" +CREATE UNIQUE INDEX "apitoken_name_organization_id" ON "api_tokens" ("name", "organization_id") WHERE ((revoked_at IS NULL) AND (project_id IS NULL) AND ((scope IS NULL) OR ((scope)::text <> 'product'::text))); +-- Create index "apitoken_name_scope_id" to table: "api_tokens" +CREATE UNIQUE INDEX "apitoken_name_scope_id" ON "api_tokens" ("name", "scope_id") WHERE ((revoked_at IS NULL) AND ((scope)::text = 'product'::text)); diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index 17a4cc9bd..f3b83bd8a 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:0gd37KIxD9roNz1eUcnatAAT/0jD8rmvTkXiV/tELO4= +h1:cJ7apFb9AvMKm63yyEuGPkWs3pmEjQ4iwsTQLCRHl9c= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -139,3 +139,4 @@ h1:0gd37KIxD9roNz1eUcnatAAT/0jD8rmvTkXiV/tELO4= 20260608210839.sql h1:RfwH7Yf8FRzqPdJeNzfIVH5TwPEush04KMAv4K1c2zY= 20260609111546.sql h1:2NQIGvPRGNb0XeCbokCSZ8CyuiuIhgbXix9XUWJok2M= 20260820221508.sql h1:avp0CjGxQsDVL9TfTisZh0A8sIQHk2awXiz432ozhQI= +20260929111949.sql h1:YJADIUmR+EjlSEIoxkAqtnHJRolFQceDba/4iWCnEHk= diff --git a/app/controlplane/pkg/data/ent/migrate/schema.go b/app/controlplane/pkg/data/ent/migrate/schema.go index 52e7c0f1c..d9cb3ae73 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -18,6 +18,8 @@ var ( {Name: "expires_at", Type: field.TypeTime, Nullable: true}, {Name: "revoked_at", Type: field.TypeTime, Nullable: true}, {Name: "last_used_at", Type: field.TypeTime, Nullable: true}, + {Name: "scope", Type: field.TypeEnum, Nullable: true, Enums: []string{"instance", "organization", "project", "group", "product"}}, + {Name: "scope_id", Type: field.TypeUUID, Nullable: true}, {Name: "policies", Type: field.TypeJSON, Nullable: true}, {Name: "is_system", Type: field.TypeBool, Default: false}, {Name: "project_id", Type: field.TypeUUID, Nullable: true}, @@ -32,19 +34,19 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "api_tokens_projects_project", - Columns: []*schema.Column{APITokensColumns[9]}, + Columns: []*schema.Column{APITokensColumns[11]}, RefColumns: []*schema.Column{ProjectsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "api_tokens_workflows_workflow", - Columns: []*schema.Column{APITokensColumns[10]}, + Columns: []*schema.Column{APITokensColumns[12]}, RefColumns: []*schema.Column{WorkflowsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "api_tokens_organizations_api_tokens", - Columns: []*schema.Column{APITokensColumns[11]}, + Columns: []*schema.Column{APITokensColumns[13]}, RefColumns: []*schema.Column{OrganizationsColumns[0]}, OnDelete: schema.Cascade, }, @@ -53,15 +55,23 @@ var ( { Name: "apitoken_name_organization_id", Unique: true, - Columns: []*schema.Column{APITokensColumns[1], APITokensColumns[11]}, + Columns: []*schema.Column{APITokensColumns[1], APITokensColumns[13]}, + Annotation: &entsql.IndexAnnotation{ + Where: "revoked_at IS NULL AND project_id IS NULL AND (scope IS NULL OR scope <> 'product')", + }, + }, + { + Name: "apitoken_name_scope_id", + Unique: true, + Columns: []*schema.Column{APITokensColumns[1], APITokensColumns[8]}, Annotation: &entsql.IndexAnnotation{ - Where: "revoked_at IS NULL AND project_id IS NULL", + Where: "revoked_at IS NULL AND scope = 'product'", }, }, { Name: "apitoken_name_project_id", Unique: true, - Columns: []*schema.Column{APITokensColumns[1], APITokensColumns[9]}, + Columns: []*schema.Column{APITokensColumns[1], APITokensColumns[11]}, Annotation: &entsql.IndexAnnotation{ Where: "revoked_at IS NULL AND project_id IS NOT NULL", }, @@ -1010,6 +1020,11 @@ func init() { APITokensTable.ForeignKeys[0].RefTable = ProjectsTable APITokensTable.ForeignKeys[1].RefTable = WorkflowsTable APITokensTable.ForeignKeys[2].RefTable = OrganizationsTable + APITokensTable.Annotation = &entsql.Annotation{} + APITokensTable.Annotation.Checks = map[string]string{ + "apitoken_scope_id_presence": "(scope_id IS NOT NULL) = (scope IS NOT NULL AND scope <> 'instance')", + "apitoken_scope_matches_token": "scope IS NULL OR (scope = 'organization' AND project_id IS NULL AND scope_id IS NOT DISTINCT FROM organization_id) OR (scope = 'project' AND scope_id IS NOT DISTINCT FROM project_id) OR (scope = 'instance' AND organization_id IS NULL AND project_id IS NULL) OR (scope = 'product' AND organization_id IS NOT NULL AND project_id IS NULL)", + } AttestationsTable.ForeignKeys[0].RefTable = WorkflowRunsTable CasBackendsTable.ForeignKeys[0].RefTable = OrganizationsTable CasMappingsTable.ForeignKeys[0].RefTable = CasBackendsTable diff --git a/app/controlplane/pkg/data/ent/mutation.go b/app/controlplane/pkg/data/ent/mutation.go index 60430e57b..80e9f1416 100644 --- a/app/controlplane/pkg/data/ent/mutation.go +++ b/app/controlplane/pkg/data/ent/mutation.go @@ -82,6 +82,8 @@ type APITokenMutation struct { expires_at *time.Time revoked_at *time.Time last_used_at *time.Time + scope *authz.ResourceType + scope_id *uuid.UUID policies *[]*authz.Policy appendpolicies []*authz.Policy is_system *bool @@ -616,6 +618,104 @@ func (m *APITokenMutation) ResetWorkflowID() { delete(m.clearedFields, apitoken.FieldWorkflowID) } +// SetScope sets the "scope" field. +func (m *APITokenMutation) SetScope(at authz.ResourceType) { + m.scope = &at +} + +// Scope returns the value of the "scope" field in the mutation. +func (m *APITokenMutation) Scope() (r authz.ResourceType, exists bool) { + v := m.scope + if v == nil { + return + } + return *v, true +} + +// OldScope returns the old "scope" field's value of the APIToken entity. +// If the APIToken object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *APITokenMutation) OldScope(ctx context.Context) (v *authz.ResourceType, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldScope is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldScope requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldScope: %w", err) + } + return oldValue.Scope, nil +} + +// ClearScope clears the value of the "scope" field. +func (m *APITokenMutation) ClearScope() { + m.scope = nil + m.clearedFields[apitoken.FieldScope] = struct{}{} +} + +// ScopeCleared returns if the "scope" field was cleared in this mutation. +func (m *APITokenMutation) ScopeCleared() bool { + _, ok := m.clearedFields[apitoken.FieldScope] + return ok +} + +// ResetScope resets all changes to the "scope" field. +func (m *APITokenMutation) ResetScope() { + m.scope = nil + delete(m.clearedFields, apitoken.FieldScope) +} + +// SetScopeID sets the "scope_id" field. +func (m *APITokenMutation) SetScopeID(u uuid.UUID) { + m.scope_id = &u +} + +// ScopeID returns the value of the "scope_id" field in the mutation. +func (m *APITokenMutation) ScopeID() (r uuid.UUID, exists bool) { + v := m.scope_id + if v == nil { + return + } + return *v, true +} + +// OldScopeID returns the old "scope_id" field's value of the APIToken entity. +// If the APIToken object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *APITokenMutation) OldScopeID(ctx context.Context) (v *uuid.UUID, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldScopeID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldScopeID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldScopeID: %w", err) + } + return oldValue.ScopeID, nil +} + +// ClearScopeID clears the value of the "scope_id" field. +func (m *APITokenMutation) ClearScopeID() { + m.scope_id = nil + m.clearedFields[apitoken.FieldScopeID] = struct{}{} +} + +// ScopeIDCleared returns if the "scope_id" field was cleared in this mutation. +func (m *APITokenMutation) ScopeIDCleared() bool { + _, ok := m.clearedFields[apitoken.FieldScopeID] + return ok +} + +// ResetScopeID resets all changes to the "scope_id" field. +func (m *APITokenMutation) ResetScopeID() { + m.scope_id = nil + delete(m.clearedFields, apitoken.FieldScopeID) +} + // SetPolicies sets the "policies" field. func (m *APITokenMutation) SetPolicies(a []*authz.Policy) { m.policies = &a @@ -832,7 +932,7 @@ func (m *APITokenMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *APITokenMutation) Fields() []string { - fields := make([]string, 0, 11) + fields := make([]string, 0, 13) if m.name != nil { fields = append(fields, apitoken.FieldName) } @@ -860,6 +960,12 @@ func (m *APITokenMutation) Fields() []string { if m.workflow != nil { fields = append(fields, apitoken.FieldWorkflowID) } + if m.scope != nil { + fields = append(fields, apitoken.FieldScope) + } + if m.scope_id != nil { + fields = append(fields, apitoken.FieldScopeID) + } if m.policies != nil { fields = append(fields, apitoken.FieldPolicies) } @@ -892,6 +998,10 @@ func (m *APITokenMutation) Field(name string) (ent.Value, bool) { return m.ProjectID() case apitoken.FieldWorkflowID: return m.WorkflowID() + case apitoken.FieldScope: + return m.Scope() + case apitoken.FieldScopeID: + return m.ScopeID() case apitoken.FieldPolicies: return m.Policies() case apitoken.FieldIsSystem: @@ -923,6 +1033,10 @@ func (m *APITokenMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldProjectID(ctx) case apitoken.FieldWorkflowID: return m.OldWorkflowID(ctx) + case apitoken.FieldScope: + return m.OldScope(ctx) + case apitoken.FieldScopeID: + return m.OldScopeID(ctx) case apitoken.FieldPolicies: return m.OldPolicies(ctx) case apitoken.FieldIsSystem: @@ -999,6 +1113,20 @@ func (m *APITokenMutation) SetField(name string, value ent.Value) error { } m.SetWorkflowID(v) return nil + case apitoken.FieldScope: + v, ok := value.(authz.ResourceType) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetScope(v) + return nil + case apitoken.FieldScopeID: + v, ok := value.(uuid.UUID) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetScopeID(v) + return nil case apitoken.FieldPolicies: v, ok := value.([]*authz.Policy) if !ok { @@ -1064,6 +1192,12 @@ func (m *APITokenMutation) ClearedFields() []string { if m.FieldCleared(apitoken.FieldWorkflowID) { fields = append(fields, apitoken.FieldWorkflowID) } + if m.FieldCleared(apitoken.FieldScope) { + fields = append(fields, apitoken.FieldScope) + } + if m.FieldCleared(apitoken.FieldScopeID) { + fields = append(fields, apitoken.FieldScopeID) + } if m.FieldCleared(apitoken.FieldPolicies) { fields = append(fields, apitoken.FieldPolicies) } @@ -1102,6 +1236,12 @@ func (m *APITokenMutation) ClearField(name string) error { case apitoken.FieldWorkflowID: m.ClearWorkflowID() return nil + case apitoken.FieldScope: + m.ClearScope() + return nil + case apitoken.FieldScopeID: + m.ClearScopeID() + return nil case apitoken.FieldPolicies: m.ClearPolicies() return nil @@ -1140,6 +1280,12 @@ func (m *APITokenMutation) ResetField(name string) error { case apitoken.FieldWorkflowID: m.ResetWorkflowID() return nil + case apitoken.FieldScope: + m.ResetScope() + return nil + case apitoken.FieldScopeID: + m.ResetScopeID() + return nil case apitoken.FieldPolicies: m.ResetPolicies() return nil diff --git a/app/controlplane/pkg/data/ent/runtime.go b/app/controlplane/pkg/data/ent/runtime.go index 6ba03a475..635399391 100644 --- a/app/controlplane/pkg/data/ent/runtime.go +++ b/app/controlplane/pkg/data/ent/runtime.go @@ -40,7 +40,7 @@ func init() { // apitoken.DefaultCreatedAt holds the default value on creation for the created_at field. apitoken.DefaultCreatedAt = apitokenDescCreatedAt.Default.(func() time.Time) // apitokenDescIsSystem is the schema descriptor for is_system field. - apitokenDescIsSystem := apitokenFields[11].Descriptor() + apitokenDescIsSystem := apitokenFields[13].Descriptor() // apitoken.DefaultIsSystem holds the default value on creation for the is_system field. apitoken.DefaultIsSystem = apitokenDescIsSystem.Default.(bool) // apitokenDescID is the schema descriptor for id field. diff --git a/app/controlplane/pkg/data/ent/schema/apitoken.go b/app/controlplane/pkg/data/ent/schema/apitoken.go index 556b1c59a..fbd642b77 100644 --- a/app/controlplane/pkg/data/ent/schema/apitoken.go +++ b/app/controlplane/pkg/data/ent/schema/apitoken.go @@ -20,6 +20,7 @@ import ( "entgo.io/ent" "entgo.io/ent/dialect/entsql" + "entgo.io/ent/schema" "entgo.io/ent/schema/edge" "entgo.io/ent/schema/field" "entgo.io/ent/schema/index" @@ -52,6 +53,12 @@ func (APIToken) Fields() []ent.Field { // Tokens can additionally be scoped to a specific workflow within a project. // Only meaningful when project_id is also set. field.UUID("workflow_id", uuid.UUID{}).Optional(), + // What the token is scoped to, and the id of that resource. Every new token records it; + // only a product scope drives any logic for now, and rows from before these columns + // existed leave both NULL. A product is not an entity here, so scope_id is a bare UUID + // with no foreign key — the same arrangement cas_mappings.product_id uses. + field.Enum("scope").GoType(authz.ResourceType("")).Optional().Nillable(), + field.UUID("scope_id", uuid.UUID{}).Optional().Nillable(), // ACL policies for this token. NULL means role-based token (future), non-NULL means ACL mode. // When set, contains the list of policies this token is allowed to perform. field.JSON("policies", []*authz.Policy{}).Optional(), @@ -60,6 +67,29 @@ func (APIToken) Fields() []ent.Field { } } +// Annotations keeps the scope columns coherent in the database itself. The rows that carry a +// product scope are written by the Chainloop platform, i.e. from outside this module, so the +// application-level checks in biz.APITokenUseCase.Create cannot be the only thing standing +// between a malformed scope and the authorization path. +// +// A scope must agree with the row it is on: an organization or project scope names the +// token's own organization or project, an instance scope has no id, and a product scope is +// never combined with a project, whose confinement would otherwise be skipped. Rows from +// before these columns existed carry no scope and pass untouched. +func (APIToken) Annotations() []schema.Annotation { + return []schema.Annotation{ + //nolint:gosec // G101 false positive: these are CHECK expressions, not credentials + entsql.Checks(map[string]string{ + "apitoken_scope_id_presence": "(scope_id IS NOT NULL) = (scope IS NOT NULL AND scope <> 'instance')", + "apitoken_scope_matches_token": "scope IS NULL" + + " OR (scope = 'organization' AND project_id IS NULL AND scope_id IS NOT DISTINCT FROM organization_id)" + + " OR (scope = 'project' AND scope_id IS NOT DISTINCT FROM project_id)" + + " OR (scope = 'instance' AND organization_id IS NULL AND project_id IS NULL)" + + " OR (scope = 'product' AND organization_id IS NOT NULL AND project_id IS NULL)", + }), + } +} + func (APIToken) Edges() []ent.Edge { return []ent.Edge{ edge.From("organization", Organization.Type).Field("organization_id").Ref("api_tokens").Unique(), @@ -71,9 +101,16 @@ func (APIToken) Edges() []ent.Edge { func (APIToken) Indexes() []ent.Index { return []ent.Index{ // names are unique within a organization and affects only to non-deleted items - // These are for org level tokens + // These are for org level tokens, which are confined to neither a project nor a + // product. Keyed on the kind, not on scope_id, so rows written before and after the + // scope columns existed share one namespace index.Fields("name").Edges("organization").Unique().Annotations( - entsql.IndexWhere("revoked_at IS NULL AND project_id IS NULL"), + entsql.IndexWhere("revoked_at IS NULL AND project_id IS NULL AND (scope IS NULL OR scope <> 'product')"), + ), + + // for product-scoped tokens, names are unique within their product + index.Fields("name", "scope_id").Unique().Annotations( + entsql.IndexWhere("revoked_at IS NULL AND scope = 'product'"), ), // for project level tokens, we scope the uniqueness to the organization and project From 41b17fafa7183157588ef03f1eb28ad8db06bfad Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Tue, 29 Sep 2026 13:35:52 +0200 Subject: [PATCH 2/8] feat(api-token): name a token's resource scope by its kind Adds the predicates that tell a token confined to a resource outside the control plane apart from an organization-wide one, and the classification of organization-level policies. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 9f11d7f0-7bde-4cda-84d4-102c5ecf87d3 --- app/controlplane/pkg/biz/apitoken.go | 62 ++++++++++-- .../pkg/biz/apitoken_scope_test.go | 97 +++++++++++++++++++ 2 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 app/controlplane/pkg/biz/apitoken_scope_test.go diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index 7a741ac3e..6652ad674 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -51,6 +51,24 @@ var orgLevelTokenPolicies = []*authz.Policy{ authz.PolicyRegisteredIntegrationRead, } +// IsOrgLevelTokenPolicy reports whether a policy is one only an organization-wide token holds. +// A token confined to a project or to a resource outside this database never carries one. +func IsOrgLevelTokenPolicy(p *authz.Policy) bool { + if p == nil { + return false + } + + return slices.ContainsFunc(orgLevelTokenPolicies, func(o *authz.Policy) bool { + return o.Resource == p.Resource && o.Action == p.Action + }) +} + +// IsResourceScopeKind reports whether a scope kind confines a token to a resource that does not +// live in this database. Only a product does. +func IsResourceScopeKind(kind authz.ResourceType) bool { + return kind == authz.ResourceTypeProduct +} + // defaultAuthzPolicies are granted to every token regardless of scope, so each entry must be safe // for a caller confined to a single project. Org-wide capabilities go in orgLevelTokenPolicies. var defaultAuthzPolicies = []*authz.Policy{ @@ -107,6 +125,29 @@ type APIToken struct { IsSystem bool } +// IsResourceScoped reports whether the token is confined to a resource outside this database, +// i.e. a product. It keys on the scope kind, never on scope_id, which every new token records. +func (t *APIToken) IsResourceScoped() bool { + return t != nil && t.Scope != nil && IsResourceScopeKind(*t.Scope) +} + +// ResourceScope returns the resource the token is confined to when that resource does not live +// in this database, so callers can render and authorize it without naming its kind. ok is false +// for every other token, and for a resource scope missing its id. +func (t *APIToken) ResourceScope() (kind authz.ResourceType, id uuid.UUID, ok bool) { + if !t.IsResourceScoped() || t.ScopeID == nil { + return "", uuid.Nil, false + } + + return *t.Scope, *t.ScopeID, true +} + +// IsOrgWide reports whether the token acts for the whole organization: confined to neither a +// project nor a resource outside this database. +func (t *APIToken) IsOrgWide() bool { + return t != nil && t.ProjectID == nil && !t.IsResourceScoped() +} + // APITokenCreateOpts is everything the repository persists for a new token. type APITokenCreateOpts struct { Name string @@ -324,6 +365,16 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description workflowID = ToPtr(options.workflow.ID) } + // Determine the scope (may be overridden by options.scope below) + scope, scopeID := newTokenScope(orgUUID, projectID) + if options.scope != nil { + if err := validateTokenScope(*options.scope, options.scopeID, orgUUID, projectID); err != nil { + return nil, err + } + + scope, scopeID = options.scope, options.scopeID + } + // Use provided policies if present, otherwise use defaults policies := options.policies if policies == nil { @@ -331,19 +382,10 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description } // Concat, not append: policies may alias the shared defaultAuthzPolicies slice. - if projectID == nil && orgUUID != nil { + if projectID == nil && !IsResourceScopeKind(*scope) && orgUUID != nil { policies = slices.Concat(policies, orgLevelTokenPolicies) } - scope, scopeID := newTokenScope(orgUUID, projectID) - if options.scope != nil { - if err := validateTokenScope(*options.scope, options.scopeID, orgUUID, projectID); err != nil { - return nil, err - } - - scope, scopeID = options.scope, options.scopeID - } - // 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, &APITokenCreateOpts{ diff --git a/app/controlplane/pkg/biz/apitoken_scope_test.go b/app/controlplane/pkg/biz/apitoken_scope_test.go new file mode 100644 index 000000000..3c6aa8b45 --- /dev/null +++ b/app/controlplane/pkg/biz/apitoken_scope_test.go @@ -0,0 +1,97 @@ +// +// 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 biz + +import ( + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +// Mirrors entities.TestAPITokenScopePredicates for the persisted row: only a product scope +// confines a token to its memberships. +func TestAPITokenScopePredicates(t *testing.T) { + t.Parallel() + + orgID, projectID, productID := uuid.New(), uuid.New(), uuid.New() + + testCases := []struct { + name string + token *APIToken + wantResourceScoped bool + wantOrgWide bool + // wantResource is the id ResourceScope reports; nil when it reports nothing + wantResource *uuid.UUID + }{ + {name: "no token", token: nil}, + {name: "an organization token from before the scope columns", token: &APIToken{}, wantOrgWide: true}, + {name: "an organization-scoped token", token: &APIToken{Scope: ToPtr(authz.ResourceTypeOrganization), ScopeID: &orgID}, wantOrgWide: true}, + {name: "a project token from before the scope columns", token: &APIToken{ProjectID: &projectID}}, + {name: "a project-scoped token", token: &APIToken{ProjectID: &projectID, Scope: ToPtr(authz.ResourceTypeProject), ScopeID: &projectID}}, + {name: "an instance-scoped token", token: &APIToken{Scope: ToPtr(authz.ResourceTypeInstance)}, wantOrgWide: true}, + {name: "a product-scoped token", token: &APIToken{Scope: ToPtr(authz.ResourceTypeProduct), ScopeID: &productID}, wantResourceScoped: true, wantResource: &productID}, + // The database refuses this row; the accessor still reports nothing rather than a zero id. + {name: "a product-scoped token without its id", token: &APIToken{Scope: ToPtr(authz.ResourceTypeProduct)}, wantResourceScoped: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.wantResourceScoped, tc.token.IsResourceScoped()) + assert.Equal(t, tc.wantOrgWide, tc.token.IsOrgWide()) + + kind, id, ok := tc.token.ResourceScope() + if tc.wantResource == nil { + assert.False(t, ok) + assert.Empty(t, kind) + assert.Equal(t, uuid.Nil, id) + return + } + + assert.True(t, ok) + assert.Equal(t, authz.ResourceTypeProduct, kind) + assert.Equal(t, *tc.wantResource, id) + }) + } +} + +// The organization-level set is what only an organization-wide token may hold. +func TestIsOrgLevelTokenPolicy(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + policy *authz.Policy + want bool + }{ + {name: "minting tokens", policy: authz.PolicyAPITokenCreate, want: true}, + {name: "listing tokens", policy: authz.PolicyAPITokenList, want: true}, + {name: "revoking tokens", policy: authz.PolicyAPITokenRevoke, want: true}, + {name: "reading registered integrations", policy: authz.PolicyRegisteredIntegrationRead, want: true}, + {name: "a default token policy", policy: authz.PolicyWorkflowRunRead, want: false}, + {name: "no policy", policy: nil, want: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, IsOrgLevelTokenPolicy(tc.policy)) + }) + } +} From 01317543d6b99d182156229f2cf483e717f08165 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Tue, 29 Sep 2026 13:46:54 +0200 Subject: [PATCH 3/8] feat(api-token): add the list of projects a product token reaches project_ids is set on product tokens only and is always a JSON array; an empty list reaches nothing. A partial index on scope_id serves the lookups of a product's tokens. project_id is marked deprecated. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 9f11d7f0-7bde-4cda-84d4-102c5ecf87d3 --- .../pkg/biz/apitoken_integration_test.go | 70 ++++++++++++++ app/controlplane/pkg/data/ent/apitoken.go | 15 ++- .../pkg/data/ent/apitoken/apitoken.go | 3 + .../pkg/data/ent/apitoken/where.go | 10 ++ .../pkg/data/ent/apitoken_create.go | 70 ++++++++++++++ .../pkg/data/ent/apitoken_update.go | 58 ++++++++++++ .../ent/migrate/migrations/20260929114226.sql | 2 + .../ent/migrate/migrations/20260929114227.sql | 4 + .../pkg/data/ent/migrate/migrations/atlas.sum | 4 +- .../pkg/data/ent/migrate/schema.go | 24 +++-- app/controlplane/pkg/data/ent/mutation.go | 92 ++++++++++++++++++- app/controlplane/pkg/data/ent/runtime.go | 2 +- .../pkg/data/ent/schema/apitoken.go | 11 +++ 13 files changed, 354 insertions(+), 11 deletions(-) create mode 100644 app/controlplane/pkg/data/ent/migrate/migrations/20260929114226.sql create mode 100644 app/controlplane/pkg/data/ent/migrate/migrations/20260929114227.sql diff --git a/app/controlplane/pkg/biz/apitoken_integration_test.go b/app/controlplane/pkg/biz/apitoken_integration_test.go index 8c042a5c0..114a13caa 100644 --- a/app/controlplane/pkg/biz/apitoken_integration_test.go +++ b/app/controlplane/pkg/biz/apitoken_integration_test.go @@ -24,6 +24,7 @@ import ( "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/chainloop-dev/chainloop/app/controlplane/pkg/data/ent" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" @@ -849,3 +850,72 @@ func (s *apiTokenTestSuite) TestListByScopeSeparatesProductFromGlobal() { s.Require().NoError(err) s.Len(projects, 3, "the project listing is unchanged") } + +// The database keeps project_ids to product tokens, and keeps it an array. +func (s *apiTokenTestSuite) TestProjectIDsConstraint() { + ctx := context.Background() + productID := uuid.New() + orgID := uuid.MustParse(s.org.ID) + product := authz.ResourceTypeProduct + organization := authz.ResourceTypeOrganization + + testCases := []struct { + name string + build func(c *ent.APITokenCreate) *ent.APITokenCreate + wantErr bool + }{ + { + name: "a product token with a list", + build: func(c *ent.APITokenCreate) *ent.APITokenCreate { + return c.SetScope(product).SetScopeID(productID).SetProjectIds([]uuid.UUID{s.p1.ID}) + }, + }, + { + name: "a product token with an empty list", + build: func(c *ent.APITokenCreate) *ent.APITokenCreate { + return c.SetScope(product).SetScopeID(productID).SetProjectIds([]uuid.UUID{}) + }, + }, + { + name: "a product token without a list", + build: func(c *ent.APITokenCreate) *ent.APITokenCreate { + return c.SetScope(product).SetScopeID(productID) + }, + wantErr: true, + }, + { + // ent writes a nil slice as JSON null, which is not SQL NULL + name: "a product token whose list is JSON null", + build: func(c *ent.APITokenCreate) *ent.APITokenCreate { + return c.SetScope(product).SetScopeID(productID).SetProjectIds(nil) + }, + wantErr: true, + }, + { + name: "an organization token with a list", + build: func(c *ent.APITokenCreate) *ent.APITokenCreate { + return c.SetScope(organization).SetScopeID(orgID).SetProjectIds([]uuid.UUID{s.p1.ID}) + }, + wantErr: true, + }, + { + name: "a legacy token with a list", + build: func(c *ent.APITokenCreate) *ent.APITokenCreate { + return c.SetProjectIds([]uuid.UUID{s.p1.ID}) + }, + wantErr: true, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + c := s.Data.DB.APIToken.Create().SetName(randomName()).SetOrganizationID(orgID) + _, err := tc.build(c).Save(ctx) + if tc.wantErr { + s.Error(err) + return + } + s.NoError(err) + }) + } +} diff --git a/app/controlplane/pkg/data/ent/apitoken.go b/app/controlplane/pkg/data/ent/apitoken.go index 8195b8fdc..d721bcb3d 100644 --- a/app/controlplane/pkg/data/ent/apitoken.go +++ b/app/controlplane/pkg/data/ent/apitoken.go @@ -45,6 +45,8 @@ type APIToken struct { Scope *authz.ResourceType `json:"scope,omitempty"` // ScopeID holds the value of the "scope_id" field. ScopeID *uuid.UUID `json:"scope_id,omitempty"` + // ProjectIds holds the value of the "project_ids" field. + ProjectIds []uuid.UUID `json:"project_ids,omitempty"` // Policies holds the value of the "policies" field. Policies []*authz.Policy `json:"policies,omitempty"` // IsSystem holds the value of the "is_system" field. @@ -108,7 +110,7 @@ func (*APIToken) scanValues(columns []string) ([]any, error) { switch columns[i] { case apitoken.FieldScopeID: values[i] = &sql.NullScanner{S: new(uuid.UUID)} - case apitoken.FieldPolicies: + case apitoken.FieldProjectIds, apitoken.FieldPolicies: values[i] = new([]byte) case apitoken.FieldIsSystem: values[i] = new(sql.NullBool) @@ -207,6 +209,14 @@ func (_m *APIToken) assignValues(columns []string, values []any) error { _m.ScopeID = new(uuid.UUID) *_m.ScopeID = *value.S.(*uuid.UUID) } + case apitoken.FieldProjectIds: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field project_ids", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.ProjectIds); err != nil { + return fmt.Errorf("unmarshal field project_ids: %w", err) + } + } case apitoken.FieldPolicies: if value, ok := values[i].(*[]byte); !ok { return fmt.Errorf("unexpected type %T for field policies", values[i]) @@ -309,6 +319,9 @@ func (_m *APIToken) String() string { builder.WriteString(fmt.Sprintf("%v", *v)) } builder.WriteString(", ") + builder.WriteString("project_ids=") + builder.WriteString(fmt.Sprintf("%v", _m.ProjectIds)) + builder.WriteString(", ") builder.WriteString("policies=") builder.WriteString(fmt.Sprintf("%v", _m.Policies)) builder.WriteString(", ") diff --git a/app/controlplane/pkg/data/ent/apitoken/apitoken.go b/app/controlplane/pkg/data/ent/apitoken/apitoken.go index bc7dfda11..863c3fc4c 100644 --- a/app/controlplane/pkg/data/ent/apitoken/apitoken.go +++ b/app/controlplane/pkg/data/ent/apitoken/apitoken.go @@ -39,6 +39,8 @@ const ( FieldScope = "scope" // FieldScopeID holds the string denoting the scope_id field in the database. FieldScopeID = "scope_id" + // FieldProjectIds holds the string denoting the project_ids field in the database. + FieldProjectIds = "project_ids" // FieldPolicies holds the string denoting the policies field in the database. FieldPolicies = "policies" // FieldIsSystem holds the string denoting the is_system field in the database. @@ -88,6 +90,7 @@ var Columns = []string{ FieldWorkflowID, FieldScope, FieldScopeID, + FieldProjectIds, FieldPolicies, FieldIsSystem, } diff --git a/app/controlplane/pkg/data/ent/apitoken/where.go b/app/controlplane/pkg/data/ent/apitoken/where.go index b109ec3f4..9cf26d7ae 100644 --- a/app/controlplane/pkg/data/ent/apitoken/where.go +++ b/app/controlplane/pkg/data/ent/apitoken/where.go @@ -622,6 +622,16 @@ func ScopeIDNotNil() predicate.APIToken { return predicate.APIToken(sql.FieldNotNull(FieldScopeID)) } +// ProjectIdsIsNil applies the IsNil predicate on the "project_ids" field. +func ProjectIdsIsNil() predicate.APIToken { + return predicate.APIToken(sql.FieldIsNull(FieldProjectIds)) +} + +// ProjectIdsNotNil applies the NotNil predicate on the "project_ids" field. +func ProjectIdsNotNil() predicate.APIToken { + return predicate.APIToken(sql.FieldNotNull(FieldProjectIds)) +} + // PoliciesIsNil applies the IsNil predicate on the "policies" field. func PoliciesIsNil() predicate.APIToken { return predicate.APIToken(sql.FieldIsNull(FieldPolicies)) diff --git a/app/controlplane/pkg/data/ent/apitoken_create.go b/app/controlplane/pkg/data/ent/apitoken_create.go index fb35f63ad..98a839bca 100644 --- a/app/controlplane/pkg/data/ent/apitoken_create.go +++ b/app/controlplane/pkg/data/ent/apitoken_create.go @@ -174,6 +174,12 @@ func (_c *APITokenCreate) SetNillableScopeID(v *uuid.UUID) *APITokenCreate { return _c } +// SetProjectIds sets the "project_ids" field. +func (_c *APITokenCreate) SetProjectIds(v []uuid.UUID) *APITokenCreate { + _c.mutation.SetProjectIds(v) + return _c +} + // SetPolicies sets the "policies" field. func (_c *APITokenCreate) SetPolicies(v []*authz.Policy) *APITokenCreate { _c.mutation.SetPolicies(v) @@ -356,6 +362,10 @@ func (_c *APITokenCreate) createSpec() (*APIToken, *sqlgraph.CreateSpec) { _spec.SetField(apitoken.FieldScopeID, field.TypeUUID, value) _node.ScopeID = &value } + if value, ok := _c.mutation.ProjectIds(); ok { + _spec.SetField(apitoken.FieldProjectIds, field.TypeJSON, value) + _node.ProjectIds = value + } if value, ok := _c.mutation.Policies(); ok { _spec.SetField(apitoken.FieldPolicies, field.TypeJSON, value) _node.Policies = value @@ -629,6 +639,24 @@ func (u *APITokenUpsert) ClearScopeID() *APITokenUpsert { return u } +// SetProjectIds sets the "project_ids" field. +func (u *APITokenUpsert) SetProjectIds(v []uuid.UUID) *APITokenUpsert { + u.Set(apitoken.FieldProjectIds, v) + return u +} + +// UpdateProjectIds sets the "project_ids" field to the value that was provided on create. +func (u *APITokenUpsert) UpdateProjectIds() *APITokenUpsert { + u.SetExcluded(apitoken.FieldProjectIds) + return u +} + +// ClearProjectIds clears the value of the "project_ids" field. +func (u *APITokenUpsert) ClearProjectIds() *APITokenUpsert { + u.SetNull(apitoken.FieldProjectIds) + return u +} + // SetPolicies sets the "policies" field. func (u *APITokenUpsert) SetPolicies(v []*authz.Policy) *APITokenUpsert { u.Set(apitoken.FieldPolicies, v) @@ -893,6 +921,27 @@ func (u *APITokenUpsertOne) ClearScopeID() *APITokenUpsertOne { }) } +// SetProjectIds sets the "project_ids" field. +func (u *APITokenUpsertOne) SetProjectIds(v []uuid.UUID) *APITokenUpsertOne { + return u.Update(func(s *APITokenUpsert) { + s.SetProjectIds(v) + }) +} + +// UpdateProjectIds sets the "project_ids" field to the value that was provided on create. +func (u *APITokenUpsertOne) UpdateProjectIds() *APITokenUpsertOne { + return u.Update(func(s *APITokenUpsert) { + s.UpdateProjectIds() + }) +} + +// ClearProjectIds clears the value of the "project_ids" field. +func (u *APITokenUpsertOne) ClearProjectIds() *APITokenUpsertOne { + return u.Update(func(s *APITokenUpsert) { + s.ClearProjectIds() + }) +} + // SetPolicies sets the "policies" field. func (u *APITokenUpsertOne) SetPolicies(v []*authz.Policy) *APITokenUpsertOne { return u.Update(func(s *APITokenUpsert) { @@ -1327,6 +1376,27 @@ func (u *APITokenUpsertBulk) ClearScopeID() *APITokenUpsertBulk { }) } +// SetProjectIds sets the "project_ids" field. +func (u *APITokenUpsertBulk) SetProjectIds(v []uuid.UUID) *APITokenUpsertBulk { + return u.Update(func(s *APITokenUpsert) { + s.SetProjectIds(v) + }) +} + +// UpdateProjectIds sets the "project_ids" field to the value that was provided on create. +func (u *APITokenUpsertBulk) UpdateProjectIds() *APITokenUpsertBulk { + return u.Update(func(s *APITokenUpsert) { + s.UpdateProjectIds() + }) +} + +// ClearProjectIds clears the value of the "project_ids" field. +func (u *APITokenUpsertBulk) ClearProjectIds() *APITokenUpsertBulk { + return u.Update(func(s *APITokenUpsert) { + s.ClearProjectIds() + }) +} + // SetPolicies sets the "policies" field. func (u *APITokenUpsertBulk) SetPolicies(v []*authz.Policy) *APITokenUpsertBulk { return u.Update(func(s *APITokenUpsert) { diff --git a/app/controlplane/pkg/data/ent/apitoken_update.go b/app/controlplane/pkg/data/ent/apitoken_update.go index 727099a88..af3a89d1f 100644 --- a/app/controlplane/pkg/data/ent/apitoken_update.go +++ b/app/controlplane/pkg/data/ent/apitoken_update.go @@ -215,6 +215,24 @@ func (_u *APITokenUpdate) ClearScopeID() *APITokenUpdate { return _u } +// SetProjectIds sets the "project_ids" field. +func (_u *APITokenUpdate) SetProjectIds(v []uuid.UUID) *APITokenUpdate { + _u.mutation.SetProjectIds(v) + return _u +} + +// AppendProjectIds appends value to the "project_ids" field. +func (_u *APITokenUpdate) AppendProjectIds(v []uuid.UUID) *APITokenUpdate { + _u.mutation.AppendProjectIds(v) + return _u +} + +// ClearProjectIds clears the value of the "project_ids" field. +func (_u *APITokenUpdate) ClearProjectIds() *APITokenUpdate { + _u.mutation.ClearProjectIds() + return _u +} + // SetPolicies sets the "policies" field. func (_u *APITokenUpdate) SetPolicies(v []*authz.Policy) *APITokenUpdate { _u.mutation.SetPolicies(v) @@ -362,6 +380,17 @@ func (_u *APITokenUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.ScopeIDCleared() { _spec.ClearField(apitoken.FieldScopeID, field.TypeUUID) } + if value, ok := _u.mutation.ProjectIds(); ok { + _spec.SetField(apitoken.FieldProjectIds, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedProjectIds(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, apitoken.FieldProjectIds, value) + }) + } + if _u.mutation.ProjectIdsCleared() { + _spec.ClearField(apitoken.FieldProjectIds, field.TypeJSON) + } if value, ok := _u.mutation.Policies(); ok { _spec.SetField(apitoken.FieldPolicies, field.TypeJSON, value) } @@ -662,6 +691,24 @@ func (_u *APITokenUpdateOne) ClearScopeID() *APITokenUpdateOne { return _u } +// SetProjectIds sets the "project_ids" field. +func (_u *APITokenUpdateOne) SetProjectIds(v []uuid.UUID) *APITokenUpdateOne { + _u.mutation.SetProjectIds(v) + return _u +} + +// AppendProjectIds appends value to the "project_ids" field. +func (_u *APITokenUpdateOne) AppendProjectIds(v []uuid.UUID) *APITokenUpdateOne { + _u.mutation.AppendProjectIds(v) + return _u +} + +// ClearProjectIds clears the value of the "project_ids" field. +func (_u *APITokenUpdateOne) ClearProjectIds() *APITokenUpdateOne { + _u.mutation.ClearProjectIds() + return _u +} + // SetPolicies sets the "policies" field. func (_u *APITokenUpdateOne) SetPolicies(v []*authz.Policy) *APITokenUpdateOne { _u.mutation.SetPolicies(v) @@ -839,6 +886,17 @@ func (_u *APITokenUpdateOne) sqlSave(ctx context.Context) (_node *APIToken, err if _u.mutation.ScopeIDCleared() { _spec.ClearField(apitoken.FieldScopeID, field.TypeUUID) } + if value, ok := _u.mutation.ProjectIds(); ok { + _spec.SetField(apitoken.FieldProjectIds, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedProjectIds(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, apitoken.FieldProjectIds, value) + }) + } + if _u.mutation.ProjectIdsCleared() { + _spec.ClearField(apitoken.FieldProjectIds, field.TypeJSON) + } if value, ok := _u.mutation.Policies(); ok { _spec.SetField(apitoken.FieldPolicies, field.TypeJSON, value) } diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260929114226.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260929114226.sql new file mode 100644 index 000000000..855e46a1c --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260929114226.sql @@ -0,0 +1,2 @@ +-- Modify "api_tokens" table +ALTER TABLE "api_tokens" ADD CONSTRAINT "apitoken_project_ids_only_for_product" CHECK (((project_ids IS NOT NULL) = (NOT ((scope)::text IS DISTINCT FROM 'product'::text))) AND ((project_ids IS NULL) OR (jsonb_typeof(project_ids) = 'array'::text))), ADD COLUMN "project_ids" jsonb NULL; diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260929114227.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260929114227.sql new file mode 100644 index 000000000..a052f1ab6 --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260929114227.sql @@ -0,0 +1,4 @@ +-- atlas:txmode none + +-- Create index "apitoken_scope_id" to table: "api_tokens" +CREATE INDEX CONCURRENTLY "apitoken_scope_id" ON "api_tokens" ("scope_id") WHERE ((scope)::text = 'product'::text) AND (revoked_at 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 f3b83bd8a..db3637ba0 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:cJ7apFb9AvMKm63yyEuGPkWs3pmEjQ4iwsTQLCRHl9c= +h1:5aplq5mCk4H5Uc/FVlfxuJ7HauFoQg2RmAyg0HLTI4g= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -140,3 +140,5 @@ h1:cJ7apFb9AvMKm63yyEuGPkWs3pmEjQ4iwsTQLCRHl9c= 20260609111546.sql h1:2NQIGvPRGNb0XeCbokCSZ8CyuiuIhgbXix9XUWJok2M= 20260820221508.sql h1:avp0CjGxQsDVL9TfTisZh0A8sIQHk2awXiz432ozhQI= 20260929111949.sql h1:YJADIUmR+EjlSEIoxkAqtnHJRolFQceDba/4iWCnEHk= +20260929114226.sql h1:+8D12heNKcrV1yDvbZDz4QvIXmgndrGV8NueJ//+swA= +20260929114227.sql h1:o7cVDpAjVWXD+9u9/53pHtMSBQqAJagTmp7kqlqpPHg= diff --git a/app/controlplane/pkg/data/ent/migrate/schema.go b/app/controlplane/pkg/data/ent/migrate/schema.go index d9cb3ae73..456fc8617 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -20,6 +20,7 @@ var ( {Name: "last_used_at", Type: field.TypeTime, Nullable: true}, {Name: "scope", Type: field.TypeEnum, Nullable: true, Enums: []string{"instance", "organization", "project", "group", "product"}}, {Name: "scope_id", Type: field.TypeUUID, Nullable: true}, + {Name: "project_ids", Type: field.TypeJSON, Nullable: true}, {Name: "policies", Type: field.TypeJSON, Nullable: true}, {Name: "is_system", Type: field.TypeBool, Default: false}, {Name: "project_id", Type: field.TypeUUID, Nullable: true}, @@ -34,19 +35,19 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "api_tokens_projects_project", - Columns: []*schema.Column{APITokensColumns[11]}, + Columns: []*schema.Column{APITokensColumns[12]}, RefColumns: []*schema.Column{ProjectsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "api_tokens_workflows_workflow", - Columns: []*schema.Column{APITokensColumns[12]}, + Columns: []*schema.Column{APITokensColumns[13]}, RefColumns: []*schema.Column{WorkflowsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "api_tokens_organizations_api_tokens", - Columns: []*schema.Column{APITokensColumns[13]}, + Columns: []*schema.Column{APITokensColumns[14]}, RefColumns: []*schema.Column{OrganizationsColumns[0]}, OnDelete: schema.Cascade, }, @@ -55,7 +56,7 @@ var ( { Name: "apitoken_name_organization_id", Unique: true, - Columns: []*schema.Column{APITokensColumns[1], APITokensColumns[13]}, + Columns: []*schema.Column{APITokensColumns[1], APITokensColumns[14]}, Annotation: &entsql.IndexAnnotation{ Where: "revoked_at IS NULL AND project_id IS NULL AND (scope IS NULL OR scope <> 'product')", }, @@ -71,7 +72,7 @@ var ( { Name: "apitoken_name_project_id", Unique: true, - Columns: []*schema.Column{APITokensColumns[1], APITokensColumns[11]}, + Columns: []*schema.Column{APITokensColumns[1], APITokensColumns[12]}, Annotation: &entsql.IndexAnnotation{ Where: "revoked_at IS NULL AND project_id IS NOT NULL", }, @@ -84,6 +85,14 @@ var ( Where: "revoked_at IS NULL AND organization_id IS NULL", }, }, + { + Name: "apitoken_scope_id", + Unique: false, + Columns: []*schema.Column{APITokensColumns[8]}, + Annotation: &entsql.IndexAnnotation{ + Where: "scope = 'product' AND revoked_at IS NULL", + }, + }, }, } // AttestationsColumns holds the columns for the "attestations" table. @@ -1022,8 +1031,9 @@ func init() { APITokensTable.ForeignKeys[2].RefTable = OrganizationsTable APITokensTable.Annotation = &entsql.Annotation{} APITokensTable.Annotation.Checks = map[string]string{ - "apitoken_scope_id_presence": "(scope_id IS NOT NULL) = (scope IS NOT NULL AND scope <> 'instance')", - "apitoken_scope_matches_token": "scope IS NULL OR (scope = 'organization' AND project_id IS NULL AND scope_id IS NOT DISTINCT FROM organization_id) OR (scope = 'project' AND scope_id IS NOT DISTINCT FROM project_id) OR (scope = 'instance' AND organization_id IS NULL AND project_id IS NULL) OR (scope = 'product' AND organization_id IS NOT NULL AND project_id IS NULL)", + "apitoken_project_ids_only_for_product": "(project_ids IS NOT NULL) = (scope IS NOT DISTINCT FROM 'product') AND (project_ids IS NULL OR jsonb_typeof(project_ids) = 'array')", + "apitoken_scope_id_presence": "(scope_id IS NOT NULL) = (scope IS NOT NULL AND scope <> 'instance')", + "apitoken_scope_matches_token": "scope IS NULL OR (scope = 'organization' AND project_id IS NULL AND scope_id IS NOT DISTINCT FROM organization_id) OR (scope = 'project' AND scope_id IS NOT DISTINCT FROM project_id) OR (scope = 'instance' AND organization_id IS NULL AND project_id IS NULL) OR (scope = 'product' AND organization_id IS NOT NULL AND project_id IS NULL)", } AttestationsTable.ForeignKeys[0].RefTable = WorkflowRunsTable CasBackendsTable.ForeignKeys[0].RefTable = OrganizationsTable diff --git a/app/controlplane/pkg/data/ent/mutation.go b/app/controlplane/pkg/data/ent/mutation.go index 80e9f1416..ee4e54a8d 100644 --- a/app/controlplane/pkg/data/ent/mutation.go +++ b/app/controlplane/pkg/data/ent/mutation.go @@ -84,6 +84,8 @@ type APITokenMutation struct { last_used_at *time.Time scope *authz.ResourceType scope_id *uuid.UUID + project_ids *[]uuid.UUID + appendproject_ids []uuid.UUID policies *[]*authz.Policy appendpolicies []*authz.Policy is_system *bool @@ -716,6 +718,71 @@ func (m *APITokenMutation) ResetScopeID() { delete(m.clearedFields, apitoken.FieldScopeID) } +// SetProjectIds sets the "project_ids" field. +func (m *APITokenMutation) SetProjectIds(u []uuid.UUID) { + m.project_ids = &u + m.appendproject_ids = nil +} + +// ProjectIds returns the value of the "project_ids" field in the mutation. +func (m *APITokenMutation) ProjectIds() (r []uuid.UUID, exists bool) { + v := m.project_ids + if v == nil { + return + } + return *v, true +} + +// OldProjectIds returns the old "project_ids" field's value of the APIToken entity. +// If the APIToken object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *APITokenMutation) OldProjectIds(ctx context.Context) (v []uuid.UUID, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldProjectIds is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldProjectIds requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldProjectIds: %w", err) + } + return oldValue.ProjectIds, nil +} + +// AppendProjectIds adds u to the "project_ids" field. +func (m *APITokenMutation) AppendProjectIds(u []uuid.UUID) { + m.appendproject_ids = append(m.appendproject_ids, u...) +} + +// AppendedProjectIds returns the list of values that were appended to the "project_ids" field in this mutation. +func (m *APITokenMutation) AppendedProjectIds() ([]uuid.UUID, bool) { + if len(m.appendproject_ids) == 0 { + return nil, false + } + return m.appendproject_ids, true +} + +// ClearProjectIds clears the value of the "project_ids" field. +func (m *APITokenMutation) ClearProjectIds() { + m.project_ids = nil + m.appendproject_ids = nil + m.clearedFields[apitoken.FieldProjectIds] = struct{}{} +} + +// ProjectIdsCleared returns if the "project_ids" field was cleared in this mutation. +func (m *APITokenMutation) ProjectIdsCleared() bool { + _, ok := m.clearedFields[apitoken.FieldProjectIds] + return ok +} + +// ResetProjectIds resets all changes to the "project_ids" field. +func (m *APITokenMutation) ResetProjectIds() { + m.project_ids = nil + m.appendproject_ids = nil + delete(m.clearedFields, apitoken.FieldProjectIds) +} + // SetPolicies sets the "policies" field. func (m *APITokenMutation) SetPolicies(a []*authz.Policy) { m.policies = &a @@ -932,7 +999,7 @@ func (m *APITokenMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *APITokenMutation) Fields() []string { - fields := make([]string, 0, 13) + fields := make([]string, 0, 14) if m.name != nil { fields = append(fields, apitoken.FieldName) } @@ -966,6 +1033,9 @@ func (m *APITokenMutation) Fields() []string { if m.scope_id != nil { fields = append(fields, apitoken.FieldScopeID) } + if m.project_ids != nil { + fields = append(fields, apitoken.FieldProjectIds) + } if m.policies != nil { fields = append(fields, apitoken.FieldPolicies) } @@ -1002,6 +1072,8 @@ func (m *APITokenMutation) Field(name string) (ent.Value, bool) { return m.Scope() case apitoken.FieldScopeID: return m.ScopeID() + case apitoken.FieldProjectIds: + return m.ProjectIds() case apitoken.FieldPolicies: return m.Policies() case apitoken.FieldIsSystem: @@ -1037,6 +1109,8 @@ func (m *APITokenMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldScope(ctx) case apitoken.FieldScopeID: return m.OldScopeID(ctx) + case apitoken.FieldProjectIds: + return m.OldProjectIds(ctx) case apitoken.FieldPolicies: return m.OldPolicies(ctx) case apitoken.FieldIsSystem: @@ -1127,6 +1201,13 @@ func (m *APITokenMutation) SetField(name string, value ent.Value) error { } m.SetScopeID(v) return nil + case apitoken.FieldProjectIds: + v, ok := value.([]uuid.UUID) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetProjectIds(v) + return nil case apitoken.FieldPolicies: v, ok := value.([]*authz.Policy) if !ok { @@ -1198,6 +1279,9 @@ func (m *APITokenMutation) ClearedFields() []string { if m.FieldCleared(apitoken.FieldScopeID) { fields = append(fields, apitoken.FieldScopeID) } + if m.FieldCleared(apitoken.FieldProjectIds) { + fields = append(fields, apitoken.FieldProjectIds) + } if m.FieldCleared(apitoken.FieldPolicies) { fields = append(fields, apitoken.FieldPolicies) } @@ -1242,6 +1326,9 @@ func (m *APITokenMutation) ClearField(name string) error { case apitoken.FieldScopeID: m.ClearScopeID() return nil + case apitoken.FieldProjectIds: + m.ClearProjectIds() + return nil case apitoken.FieldPolicies: m.ClearPolicies() return nil @@ -1286,6 +1373,9 @@ func (m *APITokenMutation) ResetField(name string) error { case apitoken.FieldScopeID: m.ResetScopeID() return nil + case apitoken.FieldProjectIds: + m.ResetProjectIds() + return nil case apitoken.FieldPolicies: m.ResetPolicies() return nil diff --git a/app/controlplane/pkg/data/ent/runtime.go b/app/controlplane/pkg/data/ent/runtime.go index 635399391..c0614b24a 100644 --- a/app/controlplane/pkg/data/ent/runtime.go +++ b/app/controlplane/pkg/data/ent/runtime.go @@ -40,7 +40,7 @@ func init() { // apitoken.DefaultCreatedAt holds the default value on creation for the created_at field. apitoken.DefaultCreatedAt = apitokenDescCreatedAt.Default.(func() time.Time) // apitokenDescIsSystem is the schema descriptor for is_system field. - apitokenDescIsSystem := apitokenFields[13].Descriptor() + apitokenDescIsSystem := apitokenFields[14].Descriptor() // apitoken.DefaultIsSystem holds the default value on creation for the is_system field. apitoken.DefaultIsSystem = apitokenDescIsSystem.Default.(bool) // apitokenDescID is the schema descriptor for id field. diff --git a/app/controlplane/pkg/data/ent/schema/apitoken.go b/app/controlplane/pkg/data/ent/schema/apitoken.go index fbd642b77..aa810619f 100644 --- a/app/controlplane/pkg/data/ent/schema/apitoken.go +++ b/app/controlplane/pkg/data/ent/schema/apitoken.go @@ -49,6 +49,7 @@ func (APIToken) Fields() []ent.Field { field.UUID("organization_id", uuid.UUID{}).Optional(), // Tokens can be associated with a project // if this value is not set, the token is an organization level token + // Deprecated: project tokens move to project_ids; product tokens already use it. field.UUID("project_id", uuid.UUID{}).Optional(), // Tokens can additionally be scoped to a specific workflow within a project. // Only meaningful when project_id is also set. @@ -59,6 +60,9 @@ func (APIToken) Fields() []ent.Field { // with no foreign key — the same arrangement cas_mappings.product_id uses. field.Enum("scope").GoType(authz.ResourceType("")).Optional().Nillable(), field.UUID("scope_id", uuid.UUID{}).Optional().Nillable(), + // The projects a product token reaches. Set on product tokens only; the Chainloop + // platform keeps it consolidated as the product changes. An empty list reaches nothing. + field.JSON("project_ids", []uuid.UUID{}).Optional(), // ACL policies for this token. NULL means role-based token (future), non-NULL means ACL mode. // When set, contains the list of policies this token is allowed to perform. field.JSON("policies", []*authz.Policy{}).Optional(), @@ -86,6 +90,8 @@ func (APIToken) Annotations() []schema.Annotation { " OR (scope = 'project' AND scope_id IS NOT DISTINCT FROM project_id)" + " OR (scope = 'instance' AND organization_id IS NULL AND project_id IS NULL)" + " OR (scope = 'product' AND organization_id IS NOT NULL AND project_id IS NULL)", + "apitoken_project_ids_only_for_product": "(project_ids IS NOT NULL) = (scope IS NOT DISTINCT FROM 'product')" + + " AND (project_ids IS NULL OR jsonb_typeof(project_ids) = 'array')", }), } } @@ -122,5 +128,10 @@ func (APIToken) Indexes() []ent.Index { index.Fields("name").Unique().Annotations( entsql.IndexWhere("revoked_at IS NULL AND organization_id IS NULL"), ), + + // the Chainloop platform looks a product's tokens up by product to keep their lists current + index.Fields("scope_id").Annotations( + entsql.IndexWhere("scope = 'product' AND revoked_at IS NULL"), + ), } } From f81f713cd208f78b8f4616db0d9834fed1780c5b Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Tue, 29 Sep 2026 14:01:44 +0200 Subject: [PATCH 4/8] feat(api-token): store the projects a product token reaches The repository stores the list deduplicated and sorted, and only when every id is a live project of the token's organization. The use case refuses a list on any token that is not product-scoped. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 9f11d7f0-7bde-4cda-84d4-102c5ecf87d3 --- app/controlplane/pkg/biz/apitoken.go | 51 +++++++-- .../pkg/biz/apitoken_integration_test.go | 104 ++++++++++++++++-- app/controlplane/pkg/data/apitoken.go | 45 +++++++- 3 files changed, 177 insertions(+), 23 deletions(-) diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index 6652ad674..50740e1e0 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -16,6 +16,7 @@ package biz import ( + "bytes" "context" "fmt" "slices" @@ -119,6 +120,9 @@ type APIToken struct { // A product's name is not stored: it belongs to whoever owns the product. Scope *authz.ResourceType ScopeID *uuid.UUID + // ProjectIDs are the projects a product token reaches, kept consolidated by the Chainloop + // platform. Never nil on a product token, nil on every other kind. + ProjectIDs []uuid.UUID // ACL policies for this token Policies []*authz.Policy // IsSystem marks tokens minted by internal code paths; these are hidden from the public API. @@ -158,10 +162,12 @@ type APITokenCreateOpts struct { WorkflowID *uuid.UUID // Scope records what the token is scoped to. ScopeID names that resource, and is unset only // for an instance-level token. - Scope *authz.ResourceType - ScopeID *uuid.UUID - Policies []*authz.Policy - IsSystem bool + Scope *authz.ResourceType + ScopeID *uuid.UUID + // ProjectIDs is set on a product token only + ProjectIDs []uuid.UUID + Policies []*authz.Policy + IsSystem bool } type APITokenRepo interface { @@ -214,12 +220,13 @@ func NewAPITokenUseCase(apiTokenRepo APITokenRepo, jwtConfig *APITokenJWTConfig, } type apiTokenOptions struct { - project *Project - workflow *Workflow - scope *authz.ResourceType - scopeID *uuid.UUID - policies []*authz.Policy - isSystem bool + project *Project + workflow *Workflow + scope *authz.ResourceType + scopeID *uuid.UUID + projectIDs []uuid.UUID + policies []*authz.Policy + isSystem bool } type APITokenCreateOpt func(*apiTokenOptions) @@ -262,6 +269,24 @@ func APITokenWithScope(scope authz.ResourceType, scopeID *uuid.UUID) APITokenCre } } +// APITokenWithProjectIDs sets the projects a product-scoped token reaches, and is refused with +// any other scope. A nil list reaches nothing, as an empty one does. +func APITokenWithProjectIDs(ids []uuid.UUID) APITokenCreateOpt { + return func(o *apiTokenOptions) { + o.projectIDs = CanonicalProjectIDs(ids) + } +} + +// CanonicalProjectIDs returns ids deduplicated and sorted, never nil, so that equal sets are +// stored as equal lists and compare equal. +func CanonicalProjectIDs(ids []uuid.UUID) []uuid.UUID { + out := make([]uuid.UUID, 0, len(ids)) + out = append(out, ids...) + slices.SortFunc(out, func(a, b uuid.UUID) int { return bytes.Compare(a[:], b[:]) }) + + return slices.Compact(out) +} + // validateTokenScope checks that an explicit scope agrees with the organization and project the // token is created for, which stay the fields the control plane reads for these kinds. func validateTokenScope(scope authz.ResourceType, scopeID, orgID, projectID *uuid.UUID) error { @@ -375,6 +400,11 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description scope, scopeID = options.scope, options.scopeID } + // Only a product token carries a project list. + if !IsResourceScopeKind(*scope) && options.projectIDs != nil { + return nil, NewErrValidationStr("only a product-scoped token carries a project list") + } + // Use provided policies if present, otherwise use defaults policies := options.policies if policies == nil { @@ -397,6 +427,7 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description WorkflowID: workflowID, Scope: scope, ScopeID: scopeID, + ProjectIDs: options.projectIDs, Policies: policies, IsSystem: options.isSystem, }) diff --git a/app/controlplane/pkg/biz/apitoken_integration_test.go b/app/controlplane/pkg/biz/apitoken_integration_test.go index 114a13caa..41819595e 100644 --- a/app/controlplane/pkg/biz/apitoken_integration_test.go +++ b/app/controlplane/pkg/biz/apitoken_integration_test.go @@ -544,6 +544,7 @@ func (s *apiTokenTestSuite) TestRepoPersistsAndReadsTheResourceScope() { OrganizationID: &orgUUID, Scope: biz.ToPtr(authz.ResourceTypeProduct), ScopeID: &productID, + ProjectIDs: []uuid.UUID{}, Policies: []*authz.Policy{}, }) s.Require().NoError(err) @@ -708,18 +709,19 @@ func (s *apiTokenTestSuite) TestRepoScopeMustAgreeWithTheToken() { productID := uuid.New() testCases := []struct { - name string - org *uuid.UUID - projectID *uuid.UUID - scope *authz.ResourceType - scopeID *uuid.UUID - wantErr bool + name string + org *uuid.UUID + projectID *uuid.UUID + scope *authz.ResourceType + scopeID *uuid.UUID + projectIDs []uuid.UUID + wantErr bool }{ {name: "a token from before this change carries no scope", org: &orgUUID}, {name: "an organization scope naming its organization", org: &orgUUID, scope: biz.ToPtr(authz.ResourceTypeOrganization), scopeID: &orgUUID}, {name: "a project scope naming its project", org: &orgUUID, projectID: &s.p1.ID, scope: biz.ToPtr(authz.ResourceTypeProject), scopeID: &s.p1.ID}, {name: "an instance scope with no id", scope: biz.ToPtr(authz.ResourceTypeInstance)}, - {name: "a product scope", org: &orgUUID, scope: biz.ToPtr(authz.ResourceTypeProduct), scopeID: &productID}, + {name: "a product scope", org: &orgUUID, scope: biz.ToPtr(authz.ResourceTypeProduct), scopeID: &productID, projectIDs: []uuid.UUID{}}, {name: "an organization scope naming another organization", org: &orgUUID, scope: biz.ToPtr(authz.ResourceTypeOrganization), scopeID: &otherOrg, wantErr: true}, {name: "an organization scope on a project token", org: &orgUUID, projectID: &s.p1.ID, scope: biz.ToPtr(authz.ResourceTypeOrganization), scopeID: &orgUUID, wantErr: true}, @@ -739,7 +741,7 @@ func (s *apiTokenTestSuite) TestRepoScopeMustAgreeWithTheToken() { s.Run(tc.name, func() { _, err := s.Repos.APITokenRepo.Create(ctx, &biz.APITokenCreateOpts{ Name: randomName(), OrganizationID: tc.org, ProjectID: tc.projectID, - Scope: tc.scope, ScopeID: tc.scopeID, Policies: []*authz.Policy{}, + Scope: tc.scope, ScopeID: tc.scopeID, ProjectIDs: tc.projectIDs, Policies: []*authz.Policy{}, }) if !tc.wantErr { s.NoError(err) @@ -763,7 +765,7 @@ func (s *apiTokenTestSuite) TestRepoScopedTokenNameUniqueness() { _, err := s.Repos.APITokenRepo.Create(ctx, &biz.APITokenCreateOpts{ Name: name, OrganizationID: &orgUUID, Scope: biz.ToPtr(authz.ResourceTypeProduct), ScopeID: &productID, - Policies: []*authz.Policy{}, + ProjectIDs: []uuid.UUID{}, Policies: []*authz.Policy{}, }) return err } @@ -816,7 +818,7 @@ func (s *apiTokenTestSuite) TestListByScopeSeparatesProductFromGlobal() { _, err := s.Repos.APITokenRepo.Create(ctx, &biz.APITokenCreateOpts{ Name: productTokenName, OrganizationID: &orgUUID, Scope: biz.ToPtr(authz.ResourceTypeProduct), ScopeID: &productID, - Policies: []*authz.Policy{}, + ProjectIDs: []uuid.UUID{}, Policies: []*authz.Policy{}, }) s.Require().NoError(err) @@ -919,3 +921,85 @@ func (s *apiTokenTestSuite) TestProjectIDsConstraint() { }) } } + +// The repository stores a product token's list in canonical form, and only live projects of the +// token's organization. The use case cannot create a product token until the control plane +// confines it; the platform writes these rows through the repository's validation all the same. +func (s *apiTokenTestSuite) TestRepoStoresTheProjectList() { + ctx := context.Background() + orgID := uuid.MustParse(s.org.ID) + productID := uuid.New() + product := authz.ResourceTypeProduct + deleted, err := s.Project.Create(ctx, s.org.ID, "deleted-project") + s.Require().NoError(err) + s.Require().NoError(s.Data.DB.Project.UpdateOneID(deleted.ID).SetDeletedAt(time.Now()).Exec(ctx)) + foreign, err := s.Project.Create(ctx, s.org2.ID, "foreign-project") + s.Require().NoError(err) + + testCases := []struct { + name string + ids []uuid.UUID + want []uuid.UUID + wantErr bool + }{ + {name: "two projects, unsorted and repeated", ids: []uuid.UUID{s.p2.ID, s.p1.ID, s.p2.ID}, want: biz.CanonicalProjectIDs([]uuid.UUID{s.p1.ID, s.p2.ID})}, + {name: "no projects", ids: []uuid.UUID{}, want: []uuid.UUID{}}, + {name: "no list at all", ids: nil, wantErr: true}, + {name: "a project of another organization", ids: []uuid.UUID{s.p1.ID, foreign.ID}, wantErr: true}, + {name: "a deleted project", ids: []uuid.UUID{deleted.ID}, wantErr: true}, + {name: "an unknown project", ids: []uuid.UUID{uuid.New()}, wantErr: true}, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + token, err := s.Repos.APITokenRepo.Create(ctx, &biz.APITokenCreateOpts{ + Name: randomName(), OrganizationID: &orgID, Scope: &product, ScopeID: &productID, ProjectIDs: tc.ids, + }) + if tc.wantErr { + s.Require().Error(err) + s.True(biz.IsErrValidation(err), "got %v", err) + return + } + s.Require().NoError(err) + s.Equal(tc.want, token.ProjectIDs) + + reloaded, err := s.Repos.APITokenRepo.FindByID(ctx, token.ID) + s.Require().NoError(err) + s.Equal(tc.want, reloaded.ProjectIDs, "the list survives a round trip") + }) + } +} + +// Only a product token carries a project list; the use case refuses one on any other token. +func (s *apiTokenTestSuite) TestCreateRefusesAProjectListOutsideAProductScope() { + ctx := context.Background() + + testCases := []struct { + name string + opts []biz.APITokenCreateOpt + }{ + {name: "an organization token", opts: []biz.APITokenCreateOpt{biz.APITokenWithProjectIDs([]uuid.UUID{s.p1.ID})}}, + {name: "a project token", opts: []biz.APITokenCreateOpt{biz.APITokenWithProject(s.p1), biz.APITokenWithProjectIDs([]uuid.UUID{s.p1.ID})}}, + {name: "an empty list is still a list", opts: []biz.APITokenCreateOpt{biz.APITokenWithProjectIDs(nil)}}, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + _, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID, tc.opts...) + s.Require().Error(err) + s.True(biz.IsErrValidation(err), "got %v", err) + }) + } +} + +// Tokens of every other kind carry no list at all, not an empty one. +func (s *apiTokenTestSuite) TestOtherTokensCarryNoProjectList() { + ctx := context.Background() + org, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID) + s.Require().NoError(err) + project, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID, biz.APITokenWithProject(s.p1)) + s.Require().NoError(err) + + s.Nil(org.ProjectIDs) + s.Nil(project.ProjectIDs) +} diff --git a/app/controlplane/pkg/data/apitoken.go b/app/controlplane/pkg/data/apitoken.go index 4b63fe7af..d5f8acc52 100644 --- a/app/controlplane/pkg/data/apitoken.go +++ b/app/controlplane/pkg/data/apitoken.go @@ -26,6 +26,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/apitoken" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/organization" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/project" "github.com/chainloop-dev/chainloop/pkg/otelx" "github.com/go-kratos/kratos/v2/log" "github.com/google/uuid" @@ -50,7 +51,7 @@ func (r *APITokenRepo) Create(ctx context.Context, opts *biz.APITokenCreateOpts) ctx, span := otelx.Start(ctx, apiTokenRepoTracer, "APITokenRepo.Create") defer span.End() - token, err := r.data.DB.APIToken.Create(). + create := r.data.DB.APIToken.Create(). SetName(opts.Name). SetNillableDescription(opts.Description). SetNillableExpiresAt(opts.ExpiresAt). @@ -60,8 +61,18 @@ func (r *APITokenRepo) Create(ctx context.Context, opts *biz.APITokenCreateOpts) SetNillableScope(opts.Scope). SetNillableScopeID(opts.ScopeID). SetPolicies(opts.Policies). - SetIsSystem(opts.IsSystem). - Save(ctx) + SetIsSystem(opts.IsSystem) + + if opts.ProjectIDs != nil { + ids, err := r.liveProjectsInOrg(ctx, opts.OrganizationID, opts.ProjectIDs) + if err != nil { + return nil, err + } + + create = create.SetProjectIds(ids) + } + + token, err := create.Save(ctx) if err != nil { // A CHECK violation is a malformed scope, not a name clash. if sqlgraph.IsCheckConstraintError(err) { @@ -78,6 +89,33 @@ func (r *APITokenRepo) Create(ctx context.Context, opts *biz.APITokenCreateOpts) return r.FindByID(ctx, token.ID) } +// liveProjectsInOrg returns ids in canonical form after checking that every one is a live +// project of the organization. The list is written from outside this module, so this is the +// check standing between a stray id and a token that reaches another organization's project. +func (r *APITokenRepo) liveProjectsInOrg(ctx context.Context, orgID *uuid.UUID, ids []uuid.UUID) ([]uuid.UUID, error) { + canonical := biz.CanonicalProjectIDs(ids) + if len(canonical) == 0 { + return canonical, nil + } + + if orgID == nil { + return nil, biz.NewErrValidationStr("a token without an organization reaches no project") + } + + live, err := r.data.DB.Project.Query(). + Where(project.IDIn(canonical...), project.OrganizationID(*orgID), project.DeletedAtIsNil()). + Count(ctx) + if err != nil { + return nil, fmt.Errorf("checking the token's projects: %w", err) + } + + if live != len(canonical) { + return nil, biz.NewErrValidationStr("every project a token reaches must be a live project of its organization") + } + + return canonical, nil +} + func (r *APITokenRepo) FindByID(ctx context.Context, id uuid.UUID) (*biz.APIToken, error) { ctx, span := otelx.Start(ctx, apiTokenRepoTracer, "APITokenRepo.FindByID") defer span.End() @@ -298,6 +336,7 @@ func entAPITokenToBiz(t *ent.APIToken) *biz.APIToken { // workflow it has no edge to load: both values come straight off the row. result.Scope = t.Scope result.ScopeID = t.ScopeID + result.ProjectIDs = t.ProjectIds return result } From bed91a8773c2a995ba22dca8c3eec23cc86f6337 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Tue, 29 Sep 2026 18:37:11 +0200 Subject: [PATCH 5/8] fix(api-token): keep the old scope names as deprecated aliases e823afa8 removed biz.APITokenScope and its constants, breaking the Chainloop platform's main branch, which still builds tokens with chainloopbiz.WithAPITokenScope(chainloopbiz.APITokenScopeInstance). Restore them as deprecated aliases of authz.ResourceType so old callers keep compiling and listing the same tokens, and reword the stale "confined to its memberships" comment on the product case of validateTokenScope to say project list, which a later change enforces. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 9f11d7f0-7bde-4cda-84d4-102c5ecf87d3 --- app/controlplane/pkg/biz/apitoken.go | 15 ++++++++++++++- .../pkg/biz/apitoken_integration_test.go | 12 ++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index 50740e1e0..5333db51a 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -304,7 +304,7 @@ func validateTokenScope(scope authz.ResourceType, scopeID, orgID, projectID *uui return NewErrValidationStr("an instance scope has no id and belongs to an instance-level token") } case authz.ResourceTypeProduct: - // Not until the control plane confines such a token to its memberships: everything + // Not until the control plane confines such a token to its project list: everything // else in it would read a token with no project as organization-wide. return NewErrValidationStr(fmt.Sprintf("unsupported token scope %q", scope)) default: @@ -563,6 +563,19 @@ func WithAPITokenScope(scope authz.ResourceType) APITokenListOpt { } } +// Deprecated: use authz.ResourceType. Kept so that callers built against the older API, such as +// the Chainloop platform's main branch, still compile against WithAPITokenScope. +type APITokenScope = authz.ResourceType + +const ( + // Deprecated: use authz.ResourceTypeProject. + APITokenScopeProject = authz.ResourceTypeProject + // Deprecated: use authz.ResourceTypeOrganization. + APITokenScopeGlobal = authz.ResourceTypeOrganization + // Deprecated: use authz.ResourceTypeInstance. + APITokenScopeInstance = authz.ResourceTypeInstance +) + // WithIncludeSystemTokens opts the listing in to also return system-managed tokens. // By default, system tokens are hidden. func WithIncludeSystemTokens() APITokenListOpt { diff --git a/app/controlplane/pkg/biz/apitoken_integration_test.go b/app/controlplane/pkg/biz/apitoken_integration_test.go index 41819595e..68d547b27 100644 --- a/app/controlplane/pkg/biz/apitoken_integration_test.go +++ b/app/controlplane/pkg/biz/apitoken_integration_test.go @@ -352,6 +352,18 @@ func (s *apiTokenTestSuite) TestList() { s.Len(tokens, 2) }) + s.Run("the deprecated scope aliases build and list the same as authz.ResourceType", func() { + _, err := s.APIToken.Create(ctx, randomName(), nil, nil, nil, biz.APITokenWithScope(authz.ResourceTypeInstance, nil)) + require.NoError(s.T(), err) + + viaAlias, err := s.APIToken.List(ctx, "", biz.WithAPITokenScope(biz.APITokenScopeInstance)) + s.NoError(err) + viaType, err := s.APIToken.List(ctx, "", biz.WithAPITokenScope(authz.ResourceTypeInstance)) + s.NoError(err) + s.NotEmpty(viaType) + s.Equal(viaType, viaAlias) + }) + s.Run("they are org scoped", func() { tokens, err := s.APIToken.List(ctx, s.org.ID) s.NoError(err) From 7390e913bf92ac675f62644b8375a40ea4a06e0d Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 30 Sep 2026 09:52:25 +0200 Subject: [PATCH 6/8] fix(api-token): require an organization for project tokens and org listings A project-scoped token created without an organization was written with no organization and signed as an instance-level token; Create now refuses it. Listing organization tokens across organizations also returned instance tokens; the organization kind now requires an organization. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez --- app/controlplane/pkg/biz/apitoken.go | 10 +++- .../pkg/biz/apitoken_integration_test.go | 55 +++++++++++++++++-- .../pkg/biz/apitoken_scope_test.go | 4 +- .../pkg/biz/apitoken_validate_scope_test.go | 1 + app/controlplane/pkg/data/apitoken.go | 7 ++- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index 5333db51a..26c68369f 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -296,8 +296,8 @@ func validateTokenScope(scope authz.ResourceType, scopeID, orgID, projectID *uui return NewErrValidationStr("an organization scope must name the organization of an organization-level token") } case authz.ResourceTypeProject: - if projectID == nil || scopeID == nil || *scopeID != *projectID { - return NewErrValidationStr("a project scope must name the project the token is created for") + if orgID == nil || projectID == nil || scopeID == nil || *scopeID != *projectID { + return NewErrValidationStr("a project scope must name the project the token is created for, in its organization") } case authz.ResourceTypeInstance: if orgID != nil || projectID != nil || scopeID != nil { @@ -379,6 +379,12 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description projectID = ToPtr(options.project.ID) } + // A project token belongs to an organization. Without one it would be written with no + // organization and signed as an instance-level token. + if projectID != nil && orgUUID == nil { + return nil, NewErrValidationStr("a project-scoped token requires an organization") + } + var workflowID *uuid.UUID if options.workflow != nil { if options.project == nil { diff --git a/app/controlplane/pkg/biz/apitoken_integration_test.go b/app/controlplane/pkg/biz/apitoken_integration_test.go index 68d547b27..62ad6228f 100644 --- a/app/controlplane/pkg/biz/apitoken_integration_test.go +++ b/app/controlplane/pkg/biz/apitoken_integration_test.go @@ -352,16 +352,30 @@ func (s *apiTokenTestSuite) TestList() { s.Len(tokens, 2) }) - s.Run("the deprecated scope aliases build and list the same as authz.ResourceType", func() { + s.Run("the deprecated scope aliases name the same kinds as authz.ResourceType", func() { + s.Equal(authz.ResourceTypeProject, biz.APITokenScopeProject) + s.Equal(authz.ResourceTypeOrganization, biz.APITokenScopeGlobal) + s.Equal(authz.ResourceTypeInstance, biz.APITokenScopeInstance) + _, err := s.APIToken.Create(ctx, randomName(), nil, nil, nil, biz.APITokenWithScope(authz.ResourceTypeInstance, nil)) require.NoError(s.T(), err) - - viaAlias, err := s.APIToken.List(ctx, "", biz.WithAPITokenScope(biz.APITokenScopeInstance)) + tokens, err := s.APIToken.List(ctx, "", biz.WithAPITokenScope(biz.APITokenScopeInstance)) s.NoError(err) - viaType, err := s.APIToken.List(ctx, "", biz.WithAPITokenScope(authz.ResourceTypeInstance)) + s.NotEmpty(tokens) + }) + + s.Run("listing organization tokens across organizations leaves instance tokens out", func() { + instance, err := s.APIToken.Create(ctx, randomName(), nil, nil, nil, biz.APITokenWithScope(authz.ResourceTypeInstance, nil)) + require.NoError(s.T(), err) + + tokens, err := s.APIToken.List(ctx, "", biz.WithAPITokenScope(authz.ResourceTypeOrganization)) s.NoError(err) - s.NotEmpty(viaType) - s.Equal(viaType, viaAlias) + s.NotEmpty(tokens) + for _, token := range tokens { + s.NotEqual(instance.ID, token.ID, "an instance token listed as an organization token") + s.NotEqual(uuid.Nil, token.OrganizationID, "token %s has no organization", token.Name) + s.Nil(token.ProjectID) + } }) s.Run("they are org scoped", func() { @@ -1004,6 +1018,35 @@ func (s *apiTokenTestSuite) TestCreateRefusesAProjectListOutsideAProductScope() } } +// A project token always belongs to an organization. Without one, Create would write a project row +// with no organization and sign it as an instance-level token. +func (s *apiTokenTestSuite) TestCreateRefusesAProjectTokenWithoutAnOrganization() { + ctx := context.Background() + + testCases := []struct { + name string + opts []biz.APITokenCreateOpt + }{ + {name: "the scope taken from the project", opts: []biz.APITokenCreateOpt{biz.APITokenWithProject(s.p1)}}, + {name: "an explicit project scope", opts: []biz.APITokenCreateOpt{biz.APITokenWithProject(s.p1), biz.APITokenWithScope(authz.ResourceTypeProject, &s.p1.ID)}}, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + name := randomName() + _, err := s.APIToken.Create(ctx, name, nil, nil, nil, tc.opts...) + s.Require().Error(err) + s.True(biz.IsErrValidation(err), "got %v", err) + + tokens, err := s.APIToken.List(ctx, "", biz.WithAPITokenStatusFilter(biz.APITokenStatusFilterAll)) + s.Require().NoError(err) + for _, token := range tokens { + s.NotEqual(name, token.Name, "a refused create writes nothing") + } + }) + } +} + // Tokens of every other kind carry no list at all, not an empty one. func (s *apiTokenTestSuite) TestOtherTokensCarryNoProjectList() { ctx := context.Background() diff --git a/app/controlplane/pkg/biz/apitoken_scope_test.go b/app/controlplane/pkg/biz/apitoken_scope_test.go index 3c6aa8b45..898aaef73 100644 --- a/app/controlplane/pkg/biz/apitoken_scope_test.go +++ b/app/controlplane/pkg/biz/apitoken_scope_test.go @@ -23,8 +23,8 @@ import ( "github.com/stretchr/testify/assert" ) -// Mirrors entities.TestAPITokenScopePredicates for the persisted row: only a product scope -// confines a token to its memberships. +// Only a product scope makes a persisted token resource-scoped; every other kind, and a row from +// before the scope columns, keeps reading organization_id and project_id. func TestAPITokenScopePredicates(t *testing.T) { t.Parallel() diff --git a/app/controlplane/pkg/biz/apitoken_validate_scope_test.go b/app/controlplane/pkg/biz/apitoken_validate_scope_test.go index e00cdabe5..db6d6954d 100644 --- a/app/controlplane/pkg/biz/apitoken_validate_scope_test.go +++ b/app/controlplane/pkg/biz/apitoken_validate_scope_test.go @@ -49,6 +49,7 @@ func TestValidateTokenScope(t *testing.T) { {name: "project without its project", scope: authz.ResourceTypeProject, scopeID: &project, orgID: &org, wantErr: true}, {name: "project naming another project", scope: authz.ResourceTypeProject, scopeID: &otherProject, orgID: &org, projectID: &project, wantErr: true}, {name: "project with no id", scope: authz.ResourceTypeProject, orgID: &org, projectID: &project, wantErr: true}, + {name: "project without an organization", scope: authz.ResourceTypeProject, scopeID: &project, projectID: &project, wantErr: true}, {name: "instance with an id", scope: authz.ResourceTypeInstance, scopeID: &product, wantErr: true}, {name: "instance on an organization token", scope: authz.ResourceTypeInstance, orgID: &org, wantErr: true}, {name: "product is not supported yet", scope: authz.ResourceTypeProduct, scopeID: &product, orgID: &org, wantErr: true}, diff --git a/app/controlplane/pkg/data/apitoken.go b/app/controlplane/pkg/data/apitoken.go index d5f8acc52..1e5e2b421 100644 --- a/app/controlplane/pkg/data/apitoken.go +++ b/app/controlplane/pkg/data/apitoken.go @@ -190,9 +190,10 @@ func (r *APITokenRepo) List(ctx context.Context, orgID *uuid.UUID, filters *biz. case authz.ResourceTypeProduct: query = query.Where(apitoken.ScopeEQ(authz.ResourceTypeProduct)) case authz.ResourceTypeOrganization: - // Organization-wide means confined to neither a project nor a product. Keyed on the - // kind: new organization tokens carry an organization scope, older ones none. - query = query.Where(apitoken.ProjectIDIsNil(), apitoken.Or(apitoken.ScopeIsNil(), apitoken.ScopeNEQ(authz.ResourceTypeProduct))) + // Organization-wide means belonging to an organization and confined to neither a project + // nor a product. Keyed on the kind: new organization tokens carry an organization scope, + // older ones none, so the organization is what tells an older one from an instance token. + query = query.Where(apitoken.OrganizationIDNotNil(), apitoken.ProjectIDIsNil(), apitoken.Or(apitoken.ScopeIsNil(), apitoken.ScopeNEQ(authz.ResourceTypeProduct))) case authz.ResourceTypeInstance: query = query.Where(apitoken.OrganizationIDIsNil()) } From 549cfde4de56fc3d608890b88f900ffb52656f5c Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 30 Sep 2026 11:59:10 +0200 Subject: [PATCH 7/8] refactor(api-token): keep token scope rules in the application, not in CHECK constraints The scope and project-list rules for API tokens move from database CHECK constraints into ValidateTokenShape, which the repository runs before every write. The platform creates and updates these rows only through the control plane's own code, so the rules still hold for every writer. The unmerged migrations no longer add the constraints. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez --- app/controlplane/pkg/biz/apitoken.go | 39 +++++++++++ .../pkg/biz/apitoken_integration_test.go | 67 +++++-------------- .../pkg/biz/apitoken_scope_test.go | 2 +- .../pkg/biz/apitoken_validate_scope_test.go | 2 +- app/controlplane/pkg/data/apitoken.go | 10 ++- .../ent/migrate/migrations/20260929111949.sql | 2 +- .../ent/migrate/migrations/20260929114226.sql | 2 +- .../pkg/data/ent/migrate/migrations/atlas.sum | 8 +-- .../pkg/data/ent/migrate/schema.go | 6 -- .../pkg/data/ent/schema/apitoken.go | 26 ------- 10 files changed, 68 insertions(+), 96 deletions(-) diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index 26c68369f..21d3175cf 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -314,6 +314,45 @@ func validateTokenScope(scope authz.ResourceType, scopeID, orgID, projectID *uui return nil } +// ValidateTokenShape reports whether a token row is coherent: an organization or project scope +// names the token's own organization or project, an instance scope has no id, a product scope +// belongs to an organization and never to a project, and only a product token carries a project +// list, which it always does. A row from before the scope columns carries neither a scope nor a +// list. The repository checks this before every write, whoever the writer is. +func ValidateTokenShape(scope *authz.ResourceType, scopeID, orgID, projectID *uuid.UUID, projectIDs []uuid.UUID) error { + if (scope != nil && IsResourceScopeKind(*scope)) != (projectIDs != nil) { + return NewErrValidationStr("only a product-scoped token carries a project list, and it always carries one") + } + + if scope == nil { + if scopeID != nil { + return NewErrValidationStr("a scope id needs a scope kind") + } + + return nil + } + + same := func(a, b *uuid.UUID) bool { return a != nil && b != nil && *a == *b } + + var coherent bool + switch *scope { + case authz.ResourceTypeOrganization: + coherent = projectID == nil && same(scopeID, orgID) + case authz.ResourceTypeProject: + coherent = same(scopeID, projectID) + case authz.ResourceTypeInstance: + coherent = scopeID == nil && orgID == nil && projectID == nil + case authz.ResourceTypeProduct: + coherent = scopeID != nil && orgID != nil && projectID == nil + } + + if !coherent { + return NewErrValidationStr(fmt.Sprintf("a %q scope does not agree with the token it is on", *scope)) + } + + return nil +} + // newTokenScope is the scope recorded for a new token confined to the given organization and // project. Only a product scope drives any logic for now: for these kinds the columns mirror // project_id and organization_id, which stay the fields the control plane reads, and tokens diff --git a/app/controlplane/pkg/biz/apitoken_integration_test.go b/app/controlplane/pkg/biz/apitoken_integration_test.go index 62ad6228f..ae0f895f0 100644 --- a/app/controlplane/pkg/biz/apitoken_integration_test.go +++ b/app/controlplane/pkg/biz/apitoken_integration_test.go @@ -24,7 +24,6 @@ import ( "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/chainloop-dev/chainloop/app/controlplane/pkg/data/ent" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" @@ -725,8 +724,8 @@ func (s *apiTokenTestSuite) TestCreateRecordsTheScopeOfEveryNewToken() { } } -// The scope must agree with the row it is on. The database holds that because the platform -// writes these rows from outside this module; a refused row is malformed, not a name clash. +// The scope must agree with the row it is on. The repository holds that for every writer, the +// platform included; a refused row is malformed, not a name clash. // Rows from before this change carry no scope at all and are untouched. func (s *apiTokenTestSuite) TestRepoScopeMustAgreeWithTheToken() { ctx := context.Background() @@ -879,8 +878,9 @@ func (s *apiTokenTestSuite) TestListByScopeSeparatesProductFromGlobal() { s.Len(projects, 3, "the project listing is unchanged") } -// The database keeps project_ids to product tokens, and keeps it an array. -func (s *apiTokenTestSuite) TestProjectIDsConstraint() { +// The repository keeps project_ids to product tokens: a product token always carries a list, empty +// when it reaches nothing, and no other token carries one. +func (s *apiTokenTestSuite) TestRepoKeepsTheProjectListToProductTokens() { ctx := context.Background() productID := uuid.New() orgID := uuid.MustParse(s.org.ID) @@ -889,58 +889,25 @@ func (s *apiTokenTestSuite) TestProjectIDsConstraint() { testCases := []struct { name string - build func(c *ent.APITokenCreate) *ent.APITokenCreate + opts biz.APITokenCreateOpts wantErr bool }{ - { - name: "a product token with a list", - build: func(c *ent.APITokenCreate) *ent.APITokenCreate { - return c.SetScope(product).SetScopeID(productID).SetProjectIds([]uuid.UUID{s.p1.ID}) - }, - }, - { - name: "a product token with an empty list", - build: func(c *ent.APITokenCreate) *ent.APITokenCreate { - return c.SetScope(product).SetScopeID(productID).SetProjectIds([]uuid.UUID{}) - }, - }, - { - name: "a product token without a list", - build: func(c *ent.APITokenCreate) *ent.APITokenCreate { - return c.SetScope(product).SetScopeID(productID) - }, - wantErr: true, - }, - { - // ent writes a nil slice as JSON null, which is not SQL NULL - name: "a product token whose list is JSON null", - build: func(c *ent.APITokenCreate) *ent.APITokenCreate { - return c.SetScope(product).SetScopeID(productID).SetProjectIds(nil) - }, - wantErr: true, - }, - { - name: "an organization token with a list", - build: func(c *ent.APITokenCreate) *ent.APITokenCreate { - return c.SetScope(organization).SetScopeID(orgID).SetProjectIds([]uuid.UUID{s.p1.ID}) - }, - wantErr: true, - }, - { - name: "a legacy token with a list", - build: func(c *ent.APITokenCreate) *ent.APITokenCreate { - return c.SetProjectIds([]uuid.UUID{s.p1.ID}) - }, - wantErr: true, - }, + {name: "a product token with a list", opts: biz.APITokenCreateOpts{Scope: &product, ScopeID: &productID, ProjectIDs: []uuid.UUID{s.p1.ID}}}, + {name: "a product token with an empty list", opts: biz.APITokenCreateOpts{Scope: &product, ScopeID: &productID, ProjectIDs: []uuid.UUID{}}}, + {name: "a product token without a list", opts: biz.APITokenCreateOpts{Scope: &product, ScopeID: &productID}, wantErr: true}, + {name: "an organization token with a list", opts: biz.APITokenCreateOpts{Scope: &organization, ScopeID: &orgID, ProjectIDs: []uuid.UUID{s.p1.ID}}, wantErr: true}, + {name: "a token without a scope with a list", opts: biz.APITokenCreateOpts{ProjectIDs: []uuid.UUID{s.p1.ID}}, wantErr: true}, } for _, tc := range testCases { s.Run(tc.name, func() { - c := s.Data.DB.APIToken.Create().SetName(randomName()).SetOrganizationID(orgID) - _, err := tc.build(c).Save(ctx) + opts := tc.opts + opts.Name = randomName() + opts.OrganizationID = &orgID + _, err := s.Repos.APITokenRepo.Create(ctx, &opts) if tc.wantErr { - s.Error(err) + s.Require().Error(err) + s.True(biz.IsErrValidation(err), "got %v", err) return } s.NoError(err) diff --git a/app/controlplane/pkg/biz/apitoken_scope_test.go b/app/controlplane/pkg/biz/apitoken_scope_test.go index 898aaef73..e3d29719a 100644 --- a/app/controlplane/pkg/biz/apitoken_scope_test.go +++ b/app/controlplane/pkg/biz/apitoken_scope_test.go @@ -45,7 +45,7 @@ func TestAPITokenScopePredicates(t *testing.T) { {name: "a project-scoped token", token: &APIToken{ProjectID: &projectID, Scope: ToPtr(authz.ResourceTypeProject), ScopeID: &projectID}}, {name: "an instance-scoped token", token: &APIToken{Scope: ToPtr(authz.ResourceTypeInstance)}, wantOrgWide: true}, {name: "a product-scoped token", token: &APIToken{Scope: ToPtr(authz.ResourceTypeProduct), ScopeID: &productID}, wantResourceScoped: true, wantResource: &productID}, - // The database refuses this row; the accessor still reports nothing rather than a zero id. + // The repository refuses this row; the accessor still reports nothing rather than a zero id. {name: "a product-scoped token without its id", token: &APIToken{Scope: ToPtr(authz.ResourceTypeProduct)}, wantResourceScoped: true}, } diff --git a/app/controlplane/pkg/biz/apitoken_validate_scope_test.go b/app/controlplane/pkg/biz/apitoken_validate_scope_test.go index db6d6954d..650eae30e 100644 --- a/app/controlplane/pkg/biz/apitoken_validate_scope_test.go +++ b/app/controlplane/pkg/biz/apitoken_validate_scope_test.go @@ -23,7 +23,7 @@ import ( "github.com/stretchr/testify/assert" ) -// The database refuses most incoherent scopes too; this pins the check Create makes itself, +// The repository refuses incoherent scopes too; this pins the check Create makes itself, // before anything is written. func TestValidateTokenScope(t *testing.T) { t.Parallel() diff --git a/app/controlplane/pkg/data/apitoken.go b/app/controlplane/pkg/data/apitoken.go index 1e5e2b421..97ee77432 100644 --- a/app/controlplane/pkg/data/apitoken.go +++ b/app/controlplane/pkg/data/apitoken.go @@ -20,7 +20,6 @@ import ( "fmt" "time" - "entgo.io/ent/dialect/sql/sqlgraph" "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/data/ent" @@ -63,6 +62,10 @@ func (r *APITokenRepo) Create(ctx context.Context, opts *biz.APITokenCreateOpts) SetPolicies(opts.Policies). SetIsSystem(opts.IsSystem) + if err := biz.ValidateTokenShape(opts.Scope, opts.ScopeID, opts.OrganizationID, opts.ProjectID, opts.ProjectIDs); err != nil { + return nil, err + } + if opts.ProjectIDs != nil { ids, err := r.liveProjectsInOrg(ctx, opts.OrganizationID, opts.ProjectIDs) if err != nil { @@ -74,11 +77,6 @@ func (r *APITokenRepo) Create(ctx context.Context, opts *biz.APITokenCreateOpts) token, err := create.Save(ctx) if err != nil { - // A CHECK violation is a malformed scope, not a name clash. - if sqlgraph.IsCheckConstraintError(err) { - return nil, biz.NewErrValidation(err) - } - if ent.IsConstraintError(err) { return nil, biz.NewErrAlreadyExists(err) } diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260929111949.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260929111949.sql index ae2144ee1..bd5ca25c7 100644 --- a/app/controlplane/pkg/data/ent/migrate/migrations/20260929111949.sql +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260929111949.sql @@ -4,7 +4,7 @@ -- Drop index "apitoken_name_organization_id" from table: "api_tokens" DROP INDEX "apitoken_name_organization_id"; -- Modify "api_tokens" table -ALTER TABLE "api_tokens" ADD CONSTRAINT "apitoken_scope_id_presence" CHECK ((scope_id IS NOT NULL) = ((scope IS NOT NULL) AND ((scope)::text <> 'instance'::text))), ADD CONSTRAINT "apitoken_scope_matches_token" CHECK ((scope IS NULL) OR (((scope)::text = 'organization'::text) AND (project_id IS NULL) AND (NOT (scope_id IS DISTINCT FROM organization_id))) OR (((scope)::text = 'project'::text) AND (NOT (scope_id IS DISTINCT FROM project_id))) OR (((scope)::text = 'instance'::text) AND (organization_id IS NULL) AND (project_id IS NULL)) OR (((scope)::text = 'product'::text) AND (organization_id IS NOT NULL) AND (project_id IS NULL))), ADD COLUMN "scope" character varying NULL, ADD COLUMN "scope_id" uuid NULL; +ALTER TABLE "api_tokens" ADD COLUMN "scope" character varying NULL, ADD COLUMN "scope_id" uuid NULL; -- Create index "apitoken_name_organization_id" to table: "api_tokens" CREATE UNIQUE INDEX "apitoken_name_organization_id" ON "api_tokens" ("name", "organization_id") WHERE ((revoked_at IS NULL) AND (project_id IS NULL) AND ((scope IS NULL) OR ((scope)::text <> 'product'::text))); -- Create index "apitoken_name_scope_id" to table: "api_tokens" diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260929114226.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260929114226.sql index 855e46a1c..348b136e3 100644 --- a/app/controlplane/pkg/data/ent/migrate/migrations/20260929114226.sql +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260929114226.sql @@ -1,2 +1,2 @@ -- Modify "api_tokens" table -ALTER TABLE "api_tokens" ADD CONSTRAINT "apitoken_project_ids_only_for_product" CHECK (((project_ids IS NOT NULL) = (NOT ((scope)::text IS DISTINCT FROM 'product'::text))) AND ((project_ids IS NULL) OR (jsonb_typeof(project_ids) = 'array'::text))), ADD COLUMN "project_ids" jsonb NULL; +ALTER TABLE "api_tokens" ADD COLUMN "project_ids" jsonb NULL; diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index db3637ba0..28dd3aa13 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:5aplq5mCk4H5Uc/FVlfxuJ7HauFoQg2RmAyg0HLTI4g= +h1:yFi+Kp8Y2UDw5tLCJQyyg6wt+fHArS027edBOALDARA= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -139,6 +139,6 @@ h1:5aplq5mCk4H5Uc/FVlfxuJ7HauFoQg2RmAyg0HLTI4g= 20260608210839.sql h1:RfwH7Yf8FRzqPdJeNzfIVH5TwPEush04KMAv4K1c2zY= 20260609111546.sql h1:2NQIGvPRGNb0XeCbokCSZ8CyuiuIhgbXix9XUWJok2M= 20260820221508.sql h1:avp0CjGxQsDVL9TfTisZh0A8sIQHk2awXiz432ozhQI= -20260929111949.sql h1:YJADIUmR+EjlSEIoxkAqtnHJRolFQceDba/4iWCnEHk= -20260929114226.sql h1:+8D12heNKcrV1yDvbZDz4QvIXmgndrGV8NueJ//+swA= -20260929114227.sql h1:o7cVDpAjVWXD+9u9/53pHtMSBQqAJagTmp7kqlqpPHg= +20260929111949.sql h1:lIKrFWubJtLbYQRA7XniWLUcr6DJUoa3Ju8c7EWFg3E= +20260929114226.sql h1:i1wXCIf0gb5fhHkrEj7QAFaJ4LyghZ9+jkSacc1QN2w= +20260929114227.sql h1:oulkhKH6cKhFva/5mYx6aSaFarM6YgeuTh0Plw8X38E= diff --git a/app/controlplane/pkg/data/ent/migrate/schema.go b/app/controlplane/pkg/data/ent/migrate/schema.go index 456fc8617..561e108ab 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -1029,12 +1029,6 @@ func init() { APITokensTable.ForeignKeys[0].RefTable = ProjectsTable APITokensTable.ForeignKeys[1].RefTable = WorkflowsTable APITokensTable.ForeignKeys[2].RefTable = OrganizationsTable - APITokensTable.Annotation = &entsql.Annotation{} - APITokensTable.Annotation.Checks = map[string]string{ - "apitoken_project_ids_only_for_product": "(project_ids IS NOT NULL) = (scope IS NOT DISTINCT FROM 'product') AND (project_ids IS NULL OR jsonb_typeof(project_ids) = 'array')", - "apitoken_scope_id_presence": "(scope_id IS NOT NULL) = (scope IS NOT NULL AND scope <> 'instance')", - "apitoken_scope_matches_token": "scope IS NULL OR (scope = 'organization' AND project_id IS NULL AND scope_id IS NOT DISTINCT FROM organization_id) OR (scope = 'project' AND scope_id IS NOT DISTINCT FROM project_id) OR (scope = 'instance' AND organization_id IS NULL AND project_id IS NULL) OR (scope = 'product' AND organization_id IS NOT NULL AND project_id IS NULL)", - } AttestationsTable.ForeignKeys[0].RefTable = WorkflowRunsTable CasBackendsTable.ForeignKeys[0].RefTable = OrganizationsTable CasMappingsTable.ForeignKeys[0].RefTable = CasBackendsTable diff --git a/app/controlplane/pkg/data/ent/schema/apitoken.go b/app/controlplane/pkg/data/ent/schema/apitoken.go index aa810619f..a6bbc5602 100644 --- a/app/controlplane/pkg/data/ent/schema/apitoken.go +++ b/app/controlplane/pkg/data/ent/schema/apitoken.go @@ -20,7 +20,6 @@ import ( "entgo.io/ent" "entgo.io/ent/dialect/entsql" - "entgo.io/ent/schema" "entgo.io/ent/schema/edge" "entgo.io/ent/schema/field" "entgo.io/ent/schema/index" @@ -71,31 +70,6 @@ func (APIToken) Fields() []ent.Field { } } -// Annotations keeps the scope columns coherent in the database itself. The rows that carry a -// product scope are written by the Chainloop platform, i.e. from outside this module, so the -// application-level checks in biz.APITokenUseCase.Create cannot be the only thing standing -// between a malformed scope and the authorization path. -// -// A scope must agree with the row it is on: an organization or project scope names the -// token's own organization or project, an instance scope has no id, and a product scope is -// never combined with a project, whose confinement would otherwise be skipped. Rows from -// before these columns existed carry no scope and pass untouched. -func (APIToken) Annotations() []schema.Annotation { - return []schema.Annotation{ - //nolint:gosec // G101 false positive: these are CHECK expressions, not credentials - entsql.Checks(map[string]string{ - "apitoken_scope_id_presence": "(scope_id IS NOT NULL) = (scope IS NOT NULL AND scope <> 'instance')", - "apitoken_scope_matches_token": "scope IS NULL" + - " OR (scope = 'organization' AND project_id IS NULL AND scope_id IS NOT DISTINCT FROM organization_id)" + - " OR (scope = 'project' AND scope_id IS NOT DISTINCT FROM project_id)" + - " OR (scope = 'instance' AND organization_id IS NULL AND project_id IS NULL)" + - " OR (scope = 'product' AND organization_id IS NOT NULL AND project_id IS NULL)", - "apitoken_project_ids_only_for_product": "(project_ids IS NOT NULL) = (scope IS NOT DISTINCT FROM 'product')" + - " AND (project_ids IS NULL OR jsonb_typeof(project_ids) = 'array')", - }), - } -} - func (APIToken) Edges() []ent.Edge { return []ent.Edge{ edge.From("organization", Organization.Type).Field("organization_id").Ref("api_tokens").Unique(), From e0e5090c723578a0659fe8e7d73ef025034abc6e Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 30 Sep 2026 12:09:46 +0200 Subject: [PATCH 8/8] test(api-token): say which scope rules Create adds over the repository check Assisted-by: Claude Code Signed-off-by: Javier Rodriguez --- app/controlplane/pkg/biz/apitoken_validate_scope_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/controlplane/pkg/biz/apitoken_validate_scope_test.go b/app/controlplane/pkg/biz/apitoken_validate_scope_test.go index 650eae30e..624810dd7 100644 --- a/app/controlplane/pkg/biz/apitoken_validate_scope_test.go +++ b/app/controlplane/pkg/biz/apitoken_validate_scope_test.go @@ -23,8 +23,9 @@ import ( "github.com/stretchr/testify/assert" ) -// The repository refuses incoherent scopes too; this pins the check Create makes itself, -// before anything is written. +// The repository refuses most incoherent scopes too (ValidateTokenShape), but Create is stricter: +// it also requires an organization for a project scope and refuses kinds it doesn't create yet. +// This pins the check Create makes itself, before anything is written. func TestValidateTokenScope(t *testing.T) { t.Parallel()