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..21d3175cf 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" @@ -51,6 +52,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{ @@ -96,14 +115,63 @@ 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 + // 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. 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 + 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 + // ProjectIDs is set on a product token only + ProjectIDs []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. @@ -152,10 +220,13 @@ func NewAPITokenUseCase(apiTokenRepo APITokenRepo, jwtConfig *APITokenJWTConfig, } type apiTokenOptions struct { - project *Project - workflow *Workflow - 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) @@ -188,6 +259,115 @@ 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 + } +} + +// 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 { + 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 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 { + 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 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: + return NewErrValidationStr(fmt.Sprintf("unsupported token scope %q", scope)) + } + + 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 +// 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") @@ -238,6 +418,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 { @@ -249,6 +435,21 @@ 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 + } + + // 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 { @@ -256,13 +457,25 @@ 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) } // 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, + ProjectIDs: options.projectIDs, + Policies: policies, + IsSystem: options.isSystem, + }) if err != nil { if IsErrAlreadyExists(err) { return nil, NewErrAlreadyExistsStr("name already taken") @@ -387,12 +600,27 @@ 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 } } +// 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 { @@ -401,18 +629,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 +657,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 +672,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..ae0f895f0 100644 --- a/app/controlplane/pkg/biz/apitoken_integration_test.go +++ b/app/controlplane/pkg/biz/apitoken_integration_test.go @@ -340,17 +340,43 @@ 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) }) + 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) + tokens, err := s.APIToken.List(ctx, "", biz.WithAPITokenScope(biz.APITokenScopeInstance)) + s.NoError(err) + 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(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() { tokens, err := s.APIToken.List(ctx, s.org.ID) s.NoError(err) @@ -531,3 +557,471 @@ 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, + ProjectIDs: []uuid.UUID{}, + 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 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() + 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 + 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, 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}, + {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, ProjectIDs: tc.projectIDs, 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, + ProjectIDs: []uuid.UUID{}, 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, + ProjectIDs: []uuid.UUID{}, 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") +} + +// 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) + product := authz.ResourceTypeProduct + organization := authz.ResourceTypeOrganization + + testCases := []struct { + name string + opts biz.APITokenCreateOpts + wantErr bool + }{ + {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() { + opts := tc.opts + opts.Name = randomName() + opts.OrganizationID = &orgID + _, err := s.Repos.APITokenRepo.Create(ctx, &opts) + if tc.wantErr { + s.Require().Error(err) + s.True(biz.IsErrValidation(err), "got %v", err) + return + } + s.NoError(err) + }) + } +} + +// 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) + }) + } +} + +// 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() + 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/biz/apitoken_scope_test.go b/app/controlplane/pkg/biz/apitoken_scope_test.go new file mode 100644 index 000000000..e3d29719a --- /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" +) + +// 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() + + 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 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}, + } + + 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)) + }) + } +} 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..624810dd7 --- /dev/null +++ b/app/controlplane/pkg/biz/apitoken_validate_scope_test.go @@ -0,0 +1,74 @@ +// +// 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 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() + + 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: "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}, + {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..97ee77432 100644 --- a/app/controlplane/pkg/data/apitoken.go +++ b/app/controlplane/pkg/data/apitoken.go @@ -25,6 +25,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" @@ -45,20 +46,36 @@ 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). - Save(ctx) + create := r.data.DB.APIToken.Create(). + 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) + + 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 { + return nil, err + } + + create = create.SetProjectIds(ids) + } + + token, err := create.Save(ctx) if err != nil { if ent.IsConstraintError(err) { return nil, biz.NewErrAlreadyExists(err) @@ -70,6 +87,33 @@ func (r *APITokenRepo) Create(ctx context.Context, name string, description *str 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() @@ -139,11 +183,16 @@ 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 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()) } @@ -282,5 +331,11 @@ 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 + result.ProjectIDs = t.ProjectIds + return result } diff --git a/app/controlplane/pkg/data/ent/apitoken.go b/app/controlplane/pkg/data/ent/apitoken.go index 5c63e8fc2..d721bcb3d 100644 --- a/app/controlplane/pkg/data/ent/apitoken.go +++ b/app/controlplane/pkg/data/ent/apitoken.go @@ -41,6 +41,12 @@ 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"` + // 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. @@ -102,11 +108,13 @@ func (*APIToken) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case apitoken.FieldPolicies: + case apitoken.FieldScopeID: + values[i] = &sql.NullScanner{S: new(uuid.UUID)} + case apitoken.FieldProjectIds, 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 +195,28 @@ 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.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]) @@ -279,6 +309,19 @@ 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("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 97d140169..863c3fc4c 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,12 @@ 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" + // 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. @@ -80,6 +88,9 @@ var Columns = []string{ FieldOrganizationID, FieldProjectID, FieldWorkflowID, + FieldScope, + FieldScopeID, + FieldProjectIds, FieldPolicies, FieldIsSystem, } @@ -103,6 +114,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 +177,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..9cf26d7ae 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,106 @@ 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)) +} + +// 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 169f6bd1d..98a839bca 100644 --- a/app/controlplane/pkg/data/ent/apitoken_create.go +++ b/app/controlplane/pkg/data/ent/apitoken_create.go @@ -146,6 +146,40 @@ 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 +} + +// 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) @@ -252,6 +286,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 +354,18 @@ 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.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 @@ -552,6 +603,60 @@ 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 +} + +// 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) @@ -774,6 +879,69 @@ 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() + }) +} + +// 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) { @@ -1166,6 +1334,69 @@ 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() + }) +} + +// 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 fafda7352..af3a89d1f 100644 --- a/app/controlplane/pkg/data/ent/apitoken_update.go +++ b/app/controlplane/pkg/data/ent/apitoken_update.go @@ -175,6 +175,64 @@ 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 +} + +// 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) @@ -258,6 +316,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 +333,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 +368,29 @@ 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.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) } @@ -557,6 +651,64 @@ 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 +} + +// 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) @@ -653,6 +805,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 +822,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 +874,29 @@ 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.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/20260929111949.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260929111949.sql new file mode 100644 index 000000000..bd5ca25c7 --- /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 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/20260929114226.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260929114226.sql new file mode 100644 index 000000000..348b136e3 --- /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 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 17a4cc9bd..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:0gd37KIxD9roNz1eUcnatAAT/0jD8rmvTkXiV/tELO4= +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,3 +139,6 @@ h1:0gd37KIxD9roNz1eUcnatAAT/0jD8rmvTkXiV/tELO4= 20260608210839.sql h1:RfwH7Yf8FRzqPdJeNzfIVH5TwPEush04KMAv4K1c2zY= 20260609111546.sql h1:2NQIGvPRGNb0XeCbokCSZ8CyuiuIhgbXix9XUWJok2M= 20260820221508.sql h1:avp0CjGxQsDVL9TfTisZh0A8sIQHk2awXiz432ozhQI= +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 52e7c0f1c..561e108ab 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -18,6 +18,9 @@ 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: "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}, @@ -32,19 +35,19 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "api_tokens_projects_project", - Columns: []*schema.Column{APITokensColumns[9]}, + Columns: []*schema.Column{APITokensColumns[12]}, RefColumns: []*schema.Column{ProjectsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "api_tokens_workflows_workflow", - Columns: []*schema.Column{APITokensColumns[10]}, + Columns: []*schema.Column{APITokensColumns[13]}, RefColumns: []*schema.Column{WorkflowsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "api_tokens_organizations_api_tokens", - Columns: []*schema.Column{APITokensColumns[11]}, + Columns: []*schema.Column{APITokensColumns[14]}, RefColumns: []*schema.Column{OrganizationsColumns[0]}, OnDelete: schema.Cascade, }, @@ -53,15 +56,23 @@ var ( { Name: "apitoken_name_organization_id", Unique: true, - Columns: []*schema.Column{APITokensColumns[1], APITokensColumns[11]}, + Columns: []*schema.Column{APITokensColumns[1], APITokensColumns[14]}, Annotation: &entsql.IndexAnnotation{ - Where: "revoked_at IS NULL AND project_id IS NULL", + 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 scope = 'product'", }, }, { Name: "apitoken_name_project_id", Unique: true, - Columns: []*schema.Column{APITokensColumns[1], APITokensColumns[9]}, + Columns: []*schema.Column{APITokensColumns[1], APITokensColumns[12]}, Annotation: &entsql.IndexAnnotation{ Where: "revoked_at IS NULL AND project_id IS NOT NULL", }, @@ -74,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. diff --git a/app/controlplane/pkg/data/ent/mutation.go b/app/controlplane/pkg/data/ent/mutation.go index 60430e57b..ee4e54a8d 100644 --- a/app/controlplane/pkg/data/ent/mutation.go +++ b/app/controlplane/pkg/data/ent/mutation.go @@ -82,6 +82,10 @@ type APITokenMutation struct { expires_at *time.Time revoked_at *time.Time 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 @@ -616,6 +620,169 @@ 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) +} + +// 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 @@ -832,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, 11) + fields := make([]string, 0, 14) if m.name != nil { fields = append(fields, apitoken.FieldName) } @@ -860,6 +1027,15 @@ 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.project_ids != nil { + fields = append(fields, apitoken.FieldProjectIds) + } if m.policies != nil { fields = append(fields, apitoken.FieldPolicies) } @@ -892,6 +1068,12 @@ 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.FieldProjectIds: + return m.ProjectIds() case apitoken.FieldPolicies: return m.Policies() case apitoken.FieldIsSystem: @@ -923,6 +1105,12 @@ 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.FieldProjectIds: + return m.OldProjectIds(ctx) case apitoken.FieldPolicies: return m.OldPolicies(ctx) case apitoken.FieldIsSystem: @@ -999,6 +1187,27 @@ 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.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 { @@ -1064,6 +1273,15 @@ 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.FieldProjectIds) { + fields = append(fields, apitoken.FieldProjectIds) + } if m.FieldCleared(apitoken.FieldPolicies) { fields = append(fields, apitoken.FieldPolicies) } @@ -1102,6 +1320,15 @@ 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.FieldProjectIds: + m.ClearProjectIds() + return nil case apitoken.FieldPolicies: m.ClearPolicies() return nil @@ -1140,6 +1367,15 @@ 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.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 6ba03a475..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[11].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 556b1c59a..a6bbc5602 100644 --- a/app/controlplane/pkg/data/ent/schema/apitoken.go +++ b/app/controlplane/pkg/data/ent/schema/apitoken.go @@ -48,10 +48,20 @@ 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. 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(), + // 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(), @@ -71,9 +81,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 @@ -85,5 +102,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"), + ), } }