From c1ddad36a1cc554c75b5c5375ec20d55d8f8ea5e Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Tue, 13 Jan 2026 14:01:31 +0100 Subject: [PATCH 1/9] instance admin token Signed-off-by: Sylwester Piskozub --- app/controlplane/internal/service/apitoken.go | 2 +- .../usercontext/apitoken_middleware.go | 47 +++++-- .../internal/usercontext/entities/apitoken.go | 1 + app/controlplane/pkg/authz/authz.go | 3 + app/controlplane/pkg/biz/apitoken.go | 126 ++++++++++++------ app/controlplane/pkg/data/apitoken.go | 21 ++- .../pkg/data/ent/apitoken/where.go | 10 ++ .../pkg/data/ent/apitoken_create.go | 34 ++++- .../pkg/data/ent/apitoken_update.go | 34 ++--- .../ent/migrate/migrations/20260112115927.sql | 2 + .../pkg/data/ent/migrate/migrations/atlas.sum | 3 +- .../pkg/data/ent/migrate/schema.go | 10 +- app/controlplane/pkg/data/ent/mutation.go | 21 ++- .../pkg/data/ent/schema/apitoken.go | 10 +- app/controlplane/pkg/jwt/apitoken/apitoken.go | 25 ++-- .../pkg/jwt/apitoken/apitoken_test.go | 55 ++++---- 16 files changed, 272 insertions(+), 132 deletions(-) create mode 100644 app/controlplane/pkg/data/ent/migrate/migrations/20260112115927.sql diff --git a/app/controlplane/internal/service/apitoken.go b/app/controlplane/internal/service/apitoken.go index d7f98ecf4..c81628ca6 100644 --- a/app/controlplane/internal/service/apitoken.go +++ b/app/controlplane/internal/service/apitoken.go @@ -69,7 +69,7 @@ func (s *APITokenService) Create(ctx context.Context, req *pb.APITokenServiceCre *expiresIn = req.ExpiresIn.AsDuration() } - token, err := s.APITokenUseCase.Create(ctx, req.Name, req.Description, expiresIn, currentOrg.ID, biz.APITokenWithProject(project)) + token, err := s.APITokenUseCase.Create(ctx, req.Name, req.Description, expiresIn, ¤tOrg.ID, biz.APITokenWithProject(project)) if err != nil { return nil, handleUseCaseErr(err, s.log) } diff --git a/app/controlplane/internal/usercontext/apitoken_middleware.go b/app/controlplane/internal/usercontext/apitoken_middleware.go index a26dea8e0..eb013dc34 100644 --- a/app/controlplane/internal/usercontext/apitoken_middleware.go +++ b/app/controlplane/internal/usercontext/apitoken_middleware.go @@ -74,7 +74,10 @@ func WithCurrentAPITokenAndOrgMiddleware(apiTokenUC *biz.APITokenUseCase, orgUC // Project ID is optional projectID, _ := genericClaims["project_id"].(string) - ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, projectID) + // Scope is optional + scope, _ := genericClaims["scope"].(string) + + ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, projectID, scope) if err != nil { return nil, fmt.Errorf("error setting current org and user: %w", err) } @@ -123,7 +126,7 @@ func WithAttestationContextFromAPIToken(apiTokenUC *biz.APITokenUseCase, orgUC * return nil, fmt.Errorf("error extracting organization from APIToken: %w", err) } - ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, claims.ProjectID) + ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, claims.ProjectID, claims.Scope) if err != nil { return nil, fmt.Errorf("error setting current org and user: %w", err) } @@ -160,7 +163,7 @@ func setRobotAccountFromAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUs } // Set the current organization and API-Token in the context -func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, tokenID, projectIDInClaim string) (context.Context, error) { +func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, tokenID, projectIDInClaim, scope string) (context.Context, error) { if tokenID == "" { return nil, errors.New("error retrieving the key ID from the API token") } @@ -184,16 +187,35 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa return nil, errors.New("API token revoked") } - // Find the associated organization - org, err := orgUC.FindByID(ctx, token.OrganizationID.String()) - if err != nil { - return nil, fmt.Errorf("error retrieving the organization: %w", err) - } else if org == nil { - return nil, errors.New("organization not found") - } + // Handle instance admin tokens + if scope == authz.ScopeInstanceAdmin { + // Check if org name provided in header + orgName, _ := entities.GetOrganizationNameFromHeader(ctx) + if orgName != "" { + // Load organization from header + org, err := orgUC.FindByName(ctx, orgName) + if err != nil { + return nil, fmt.Errorf("error retrieving the organization: %w", err) + } else if org == nil { + return nil, errors.New("organization not found") + } - // Set the current organization and API-Token in the context - ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt}) + ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt}) + } + // If no org header, org context remains unset, operations will either: + // 1. Work without org context + // 2. Fail appropriately if they require org context + } else { + org, err := orgUC.FindByID(ctx, token.OrganizationID.String()) + if err != nil { + return nil, fmt.Errorf("error retrieving the organization: %w", err) + } else if org == nil { + return nil, errors.New("organization not found") + } + + // Set the current organization in the context + ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt}) + } ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{ ID: token.ID.String(), @@ -203,6 +225,7 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa ProjectID: token.ProjectID, ProjectName: token.ProjectName, Policies: token.Policies, + Scope: scope, }) // Set the authorization subject that will be used to check the policies diff --git a/app/controlplane/internal/usercontext/entities/apitoken.go b/app/controlplane/internal/usercontext/entities/apitoken.go index 45638db5e..03bf1e636 100644 --- a/app/controlplane/internal/usercontext/entities/apitoken.go +++ b/app/controlplane/internal/usercontext/entities/apitoken.go @@ -33,6 +33,7 @@ type APIToken struct { ProjectName *string // ACL policies for this token. Used for authorization checks. Policies []*authz.Policy + Scope string } func WithCurrentAPIToken(ctx context.Context, token *APIToken) context.Context { diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index d9f0b42ea..bad0a6534 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -97,6 +97,9 @@ const ( // Product roles RoleProductViewer Role = "role:product:viewer" RoleProductAdmin Role = "role:product:admin" + + // Scope for instance admin tokens + ScopeInstanceAdmin = "INSTANCE_ADMIN" ) var ( diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index edd58aef0..2f9faea28 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -59,9 +59,9 @@ type APIToken struct { } type APITokenRepo interface { - Create(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID uuid.UUID, projectID *uuid.UUID, policies []*authz.Policy) (*APIToken, error) + Create(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, policies []*authz.Policy) (*APIToken, error) List(ctx context.Context, orgID *uuid.UUID, filters *APITokenListFilters) ([]*APIToken, error) - Revoke(ctx context.Context, orgID, ID uuid.UUID) error + Revoke(ctx context.Context, orgID *uuid.UUID, ID uuid.UUID) error UpdateExpiration(ctx context.Context, ID uuid.UUID, expiresAt time.Time) error UpdateLastUsedAt(ctx context.Context, ID uuid.UUID, lastUsedAt time.Time) error FindByID(ctx context.Context, ID uuid.UUID) (*APIToken, error) @@ -131,6 +131,7 @@ func NewAPITokenUseCase(apiTokenRepo APITokenRepo, jwtConfig *APITokenJWTConfig, type apiTokenOptions struct { project *Project showOnlySystemTokens bool + policies []*authz.Policy } type APITokenCreateOpt func(*apiTokenOptions) @@ -141,16 +142,34 @@ func APITokenWithProject(project *Project) APITokenCreateOpt { } } +func APITokenWithPolicies(policies []*authz.Policy) APITokenCreateOpt { + return func(o *apiTokenOptions) { + o.policies = policies + } +} + // 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) { +func (uc *APITokenUseCase) Create(ctx context.Context, name string, description *string, expiresIn *time.Duration, orgID *string, opts ...APITokenCreateOpt) (*APIToken, error) { options := &apiTokenOptions{} for _, opt := range opts { opt(options) } - orgUUID, err := uuid.Parse(orgID) - if err != nil { - return nil, NewErrInvalidUUID(err) + // Parse organization ID if provided + var orgUUID *uuid.UUID + var org *Organization + if orgID != nil && *orgID != "" { + parsed, err := uuid.Parse(*orgID) + if err != nil { + return nil, NewErrInvalidUUID(err) + } + orgUUID = &parsed + + // Retrieve the organization + org, err = uc.orgUseCase.FindByID(ctx, *orgID) + if err != nil { + return nil, fmt.Errorf("finding organization: %w", err) + } } if name == "" { @@ -170,22 +189,21 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description *expiresAt = time.Now().Add(*expiresIn) } - // Retrieve the organization - org, err := uc.orgUseCase.FindByID(ctx, orgID) - if err != nil { - return nil, fmt.Errorf("finding organization: %w", err) - } - // If a project is provided, we store it in the token var projectID *uuid.UUID if options.project != nil { projectID = ToPtr(options.project.ID) } + // Use provided policies if present, otherwise use defaults + policies := options.policies + if policies == nil { + policies = uc.DefaultAuthzPolicies + } + // 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 - // Pass default policies to be stored with the token - token, err := uc.apiTokenRepo.Create(ctx, name, description, expiresAt, orgUUID, projectID, uc.DefaultAuthzPolicies) + token, err := uc.apiTokenRepo.Create(ctx, name, description, expiresAt, orgUUID, projectID, policies) if err != nil { if IsErrAlreadyExists(err) { return nil, NewErrAlreadyExistsStr("name already taken") @@ -194,13 +212,19 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description } generationOpts := &apitoken.GenerateJWTOptions{ - OrgID: token.OrganizationID, - OrgName: org.Name, KeyID: token.ID, KeyName: name, ExpiresAt: expiresAt, } + // Set org info if available or instance-level token scope + if org != nil { + generationOpts.OrgID = &token.OrganizationID + generationOpts.OrgName = &org.Name + } else { + generationOpts.Scope = ToPtr(authz.ScopeInstanceAdmin) + } + if projectID != nil { generationOpts.ProjectID = ToPtr(options.project.ID) generationOpts.ProjectName = ToPtr(options.project.Name) @@ -208,20 +232,21 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description // generate the JWT token.JWT, err = uc.jwtBuilder.GenerateJWT(generationOpts) - if err != nil { return nil, fmt.Errorf("generating jwt: %w", err) } // Dispatch the event to the auditor to notify the creation of the token - uc.auditorUC.Dispatch(ctx, &events.APITokenCreated{ - APITokenBase: &events.APITokenBase{ - APITokenID: &token.ID, - APITokenName: name, - }, - APITokenDescription: description, - ExpiresAt: expiresAt, - }, &orgUUID) + if orgUUID != nil { + uc.auditorUC.Dispatch(ctx, &events.APITokenCreated{ + APITokenBase: &events.APITokenBase{ + APITokenID: &token.ID, + APITokenName: name, + }, + APITokenDescription: description, + ExpiresAt: expiresAt, + }, orgUUID) + } return token, nil } @@ -239,19 +264,26 @@ func (uc *APITokenUseCase) RegenerateJWT(ctx context.Context, tokenID uuid.UUID, return nil, fmt.Errorf("finding token: %w", err) } - org, err := uc.orgUseCase.FindByID(ctx, token.OrganizationID.String()) - if err != nil { - return nil, fmt.Errorf("finding organization: %w", err) - } - generationOpts := &apitoken.GenerateJWTOptions{ - OrgID: token.OrganizationID, - OrgName: org.Name, KeyID: token.ID, KeyName: token.Name, ExpiresAt: &expiresAt, } + // Check if this is an org-scoped or instance-level token + if token.OrganizationID != uuid.Nil { + // Org-scoped token + org, err := uc.orgUseCase.FindByID(ctx, token.OrganizationID.String()) + if err != nil { + return nil, fmt.Errorf("finding organization: %w", err) + } + generationOpts.OrgID = &token.OrganizationID + generationOpts.OrgName = &org.Name + } else { + // Instance-level token + generationOpts.Scope = ToPtr(authz.ScopeInstanceAdmin) + } + // generate the JWT token.JWT, err = uc.jwtBuilder.GenerateJWT(generationOpts) if err != nil { @@ -289,13 +321,15 @@ func WithAPITokenScope(scope APITokenScope) APITokenListOpt { type APITokenScope string const ( - APITokenScopeProject APITokenScope = "project" - APITokenScopeGlobal APITokenScope = "global" + APITokenScopeProject APITokenScope = "project" + APITokenScopeGlobal APITokenScope = "global" + APITokenScopeInstance APITokenScope = "instance" ) var availableAPITokenScopes = []APITokenScope{ APITokenScopeProject, APITokenScopeGlobal, + APITokenScopeInstance, } type APITokenListFilters struct { @@ -327,9 +361,13 @@ func (uc *APITokenUseCase) List(ctx context.Context, orgID string, opts ...APITo } func (uc *APITokenUseCase) Revoke(ctx context.Context, orgID, id string) error { - orgUUID, err := uuid.Parse(orgID) - if err != nil { - return NewErrInvalidUUID(err) + var orgUUID *uuid.UUID + if orgID != "" { + parsed, err := uuid.Parse(orgID) + if err != nil { + return NewErrInvalidUUID(err) + } + orgUUID = &parsed } tokenUUID, err := uuid.Parse(id) @@ -347,12 +385,14 @@ func (uc *APITokenUseCase) Revoke(ctx context.Context, orgID, id string) error { } // Dispatch the event to the auditor to notify the revocation of the token - uc.auditorUC.Dispatch(ctx, &events.APITokenRevoked{ - APITokenBase: &events.APITokenBase{ - APITokenID: &tokenUUID, - APITokenName: token.Name, - }, - }, &orgUUID) + if orgUUID != nil { + uc.auditorUC.Dispatch(ctx, &events.APITokenRevoked{ + APITokenBase: &events.APITokenBase{ + APITokenID: &tokenUUID, + APITokenName: token.Name, + }, + }, orgUUID) + } return nil } diff --git a/app/controlplane/pkg/data/apitoken.go b/app/controlplane/pkg/data/apitoken.go index 491d819cd..2f9a97699 100644 --- a/app/controlplane/pkg/data/apitoken.go +++ b/app/controlplane/pkg/data/apitoken.go @@ -42,12 +42,12 @@ 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, policies []*authz.Policy) (*biz.APIToken, error) { +func (r *APITokenRepo) Create(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, policies []*authz.Policy) (*biz.APIToken, error) { token, err := r.data.DB.APIToken.Create(). SetName(name). SetNillableDescription(description). SetNillableExpiresAt(expiresAt). - SetOrganizationID(organizationID). + SetNillableOrganizationID(organizationID). SetNillableProjectID(projectID). SetPolicies(policies). Save(ctx) @@ -119,6 +119,8 @@ func (r *APITokenRepo) List(ctx context.Context, orgID *uuid.UUID, filters *biz. query = query.Where(apitoken.ProjectIDNotNil()) case biz.APITokenScopeGlobal: query = query.Where(apitoken.ProjectIDIsNil()) + case biz.APITokenScopeInstance: + query = query.Where(apitoken.OrganizationIDIsNil()) } if !filters.IncludeRevoked { @@ -138,11 +140,18 @@ func (r *APITokenRepo) List(ctx context.Context, orgID *uuid.UUID, filters *biz. return result, nil } -func (r *APITokenRepo) Revoke(ctx context.Context, orgID, id uuid.UUID) error { +func (r *APITokenRepo) Revoke(ctx context.Context, orgID *uuid.UUID, id uuid.UUID) error { // Update a token with id = id that has not been revoked yet and its orgID = orgID - err := r.data.DB.APIToken.UpdateOneID(id). - Where(apitoken.OrganizationIDEQ(orgID), apitoken.RevokedAtIsNil()). - SetRevokedAt(time.Now()).Exec(ctx) + update := r.data.DB.APIToken.UpdateOneID(id). + Where(apitoken.RevokedAtIsNil()) + + if orgID != nil { + update = update.Where(apitoken.OrganizationIDEQ(*orgID)) + } else { + update = update.Where(apitoken.OrganizationIDIsNil()) + } + + err := update.SetRevokedAt(time.Now()).Exec(ctx) if err != nil { if ent.IsNotFound(err) { return biz.NewErrNotFound("API token") diff --git a/app/controlplane/pkg/data/ent/apitoken/where.go b/app/controlplane/pkg/data/ent/apitoken/where.go index 0ca8398d8..1dc93b01b 100644 --- a/app/controlplane/pkg/data/ent/apitoken/where.go +++ b/app/controlplane/pkg/data/ent/apitoken/where.go @@ -446,6 +446,16 @@ func OrganizationIDNotIn(vs ...uuid.UUID) predicate.APIToken { return predicate.APIToken(sql.FieldNotIn(FieldOrganizationID, vs...)) } +// OrganizationIDIsNil applies the IsNil predicate on the "organization_id" field. +func OrganizationIDIsNil() predicate.APIToken { + return predicate.APIToken(sql.FieldIsNull(FieldOrganizationID)) +} + +// OrganizationIDNotNil applies the NotNil predicate on the "organization_id" field. +func OrganizationIDNotNil() predicate.APIToken { + return predicate.APIToken(sql.FieldNotNull(FieldOrganizationID)) +} + // ProjectIDEQ applies the EQ predicate on the "project_id" field. func ProjectIDEQ(v uuid.UUID) predicate.APIToken { return predicate.APIToken(sql.FieldEQ(FieldProjectID, v)) diff --git a/app/controlplane/pkg/data/ent/apitoken_create.go b/app/controlplane/pkg/data/ent/apitoken_create.go index 28b2fdc59..f8932fe25 100644 --- a/app/controlplane/pkg/data/ent/apitoken_create.go +++ b/app/controlplane/pkg/data/ent/apitoken_create.go @@ -109,6 +109,14 @@ func (_c *APITokenCreate) SetOrganizationID(v uuid.UUID) *APITokenCreate { return _c } +// SetNillableOrganizationID sets the "organization_id" field if the given value is not nil. +func (_c *APITokenCreate) SetNillableOrganizationID(v *uuid.UUID) *APITokenCreate { + if v != nil { + _c.SetOrganizationID(*v) + } + return _c +} + // SetProjectID sets the "project_id" field. func (_c *APITokenCreate) SetProjectID(v uuid.UUID) *APITokenCreate { _c.mutation.SetProjectID(v) @@ -206,12 +214,6 @@ 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 _, ok := _c.mutation.OrganizationID(); !ok { - return &ValidationError{Name: "organization_id", err: errors.New(`ent: missing required field "APIToken.organization_id"`)} - } - if len(_c.mutation.OrganizationIDs()) == 0 { - return &ValidationError{Name: "organization", err: errors.New(`ent: missing required edge "APIToken.organization"`)} - } return nil } @@ -446,6 +448,12 @@ func (u *APITokenUpsert) UpdateOrganizationID() *APITokenUpsert { return u } +// ClearOrganizationID clears the value of the "organization_id" field. +func (u *APITokenUpsert) ClearOrganizationID() *APITokenUpsert { + u.SetNull(apitoken.FieldOrganizationID) + return u +} + // SetProjectID sets the "project_id" field. func (u *APITokenUpsert) SetProjectID(v uuid.UUID) *APITokenUpsert { u.Set(apitoken.FieldProjectID, v) @@ -634,6 +642,13 @@ func (u *APITokenUpsertOne) UpdateOrganizationID() *APITokenUpsertOne { }) } +// ClearOrganizationID clears the value of the "organization_id" field. +func (u *APITokenUpsertOne) ClearOrganizationID() *APITokenUpsertOne { + return u.Update(func(s *APITokenUpsert) { + s.ClearOrganizationID() + }) +} + // SetProjectID sets the "project_id" field. func (u *APITokenUpsertOne) SetProjectID(v uuid.UUID) *APITokenUpsertOne { return u.Update(func(s *APITokenUpsert) { @@ -995,6 +1010,13 @@ func (u *APITokenUpsertBulk) UpdateOrganizationID() *APITokenUpsertBulk { }) } +// ClearOrganizationID clears the value of the "organization_id" field. +func (u *APITokenUpsertBulk) ClearOrganizationID() *APITokenUpsertBulk { + return u.Update(func(s *APITokenUpsert) { + s.ClearOrganizationID() + }) +} + // SetProjectID sets the "project_id" field. func (u *APITokenUpsertBulk) SetProjectID(v uuid.UUID) *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 f378f60b8..aadabaae2 100644 --- a/app/controlplane/pkg/data/ent/apitoken_update.go +++ b/app/controlplane/pkg/data/ent/apitoken_update.go @@ -128,6 +128,12 @@ func (_u *APITokenUpdate) SetNillableOrganizationID(v *uuid.UUID) *APITokenUpdat return _u } +// ClearOrganizationID clears the value of the "organization_id" field. +func (_u *APITokenUpdate) ClearOrganizationID() *APITokenUpdate { + _u.mutation.ClearOrganizationID() + return _u +} + // SetProjectID sets the "project_id" field. func (_u *APITokenUpdate) SetProjectID(v uuid.UUID) *APITokenUpdate { _u.mutation.SetProjectID(v) @@ -220,14 +226,6 @@ func (_u *APITokenUpdate) ExecX(ctx context.Context) { } } -// check runs all checks and user-defined validators on the builder. -func (_u *APITokenUpdate) check() error { - if _u.mutation.OrganizationCleared() && len(_u.mutation.OrganizationIDs()) > 0 { - return errors.New(`ent: clearing a required unique edge "APIToken.organization"`) - } - 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...) @@ -235,9 +233,6 @@ 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) { @@ -455,6 +450,12 @@ func (_u *APITokenUpdateOne) SetNillableOrganizationID(v *uuid.UUID) *APITokenUp return _u } +// ClearOrganizationID clears the value of the "organization_id" field. +func (_u *APITokenUpdateOne) ClearOrganizationID() *APITokenUpdateOne { + _u.mutation.ClearOrganizationID() + return _u +} + // SetProjectID sets the "project_id" field. func (_u *APITokenUpdateOne) SetProjectID(v uuid.UUID) *APITokenUpdateOne { _u.mutation.SetProjectID(v) @@ -560,14 +561,6 @@ func (_u *APITokenUpdateOne) ExecX(ctx context.Context) { } } -// check runs all checks and user-defined validators on the builder. -func (_u *APITokenUpdateOne) check() error { - if _u.mutation.OrganizationCleared() && len(_u.mutation.OrganizationIDs()) > 0 { - return errors.New(`ent: clearing a required unique edge "APIToken.organization"`) - } - 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...) @@ -575,9 +568,6 @@ 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 { diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260112115927.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260112115927.sql new file mode 100644 index 000000000..44fe5639f --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260112115927.sql @@ -0,0 +1,2 @@ +-- Make organization_id nullable to support instance-level API tokens +ALTER TABLE "api_tokens" ALTER COLUMN "organization_id" DROP NOT NULL; diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index aad719a8f..602c4c093 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:F5OlLQoOXh5aKu7gY5y8xBRBqjAxsW729schjakKjJk= +h1:u4YpAZJKkuQougTxmCKQwjwmksDwPdohzZeGTAB6cg8= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -122,3 +122,4 @@ h1:F5OlLQoOXh5aKu7gY5y8xBRBqjAxsW729schjakKjJk= 20251114174059.sql h1:f/wB/OlhZxIc9AVCxTNu4dFmPd1T3sCY0nS8Zb9ZS9Q= 20251212115308.sql h1:CmwHDA9X91++2dnThzk57++5sBDAGw2IQnHzO3/bRlk= 20251217164302.sql h1:OL3OCqWsMtv06RfIlQNcdLMbt4Tz91Lijpbkxqwt7zM= +20260112115927.sql h1:OHbuh0pnXDm+udI1eyRwswP6YlGC99+5DSFQNXwGjZg= diff --git a/app/controlplane/pkg/data/ent/migrate/schema.go b/app/controlplane/pkg/data/ent/migrate/schema.go index d949d1c79..14fff21d2 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -20,7 +20,7 @@ var ( {Name: "last_used_at", Type: field.TypeTime, Nullable: true}, {Name: "policies", Type: field.TypeJSON, Nullable: true}, {Name: "project_id", Type: field.TypeUUID, Nullable: true}, - {Name: "organization_id", Type: field.TypeUUID}, + {Name: "organization_id", Type: field.TypeUUID, Nullable: true}, } // APITokensTable holds the schema information for the "api_tokens" table. APITokensTable = &schema.Table{ @@ -58,6 +58,14 @@ var ( Where: "revoked_at IS NULL AND project_id IS NOT NULL", }, }, + { + Name: "apitoken_name", + Unique: true, + Columns: []*schema.Column{APITokensColumns[1]}, + Annotation: &entsql.IndexAnnotation{ + Where: "revoked_at IS NULL AND organization_id 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 d4c0c8a00..f51b8dc01 100644 --- a/app/controlplane/pkg/data/ent/mutation.go +++ b/app/controlplane/pkg/data/ent/mutation.go @@ -497,9 +497,22 @@ func (m *APITokenMutation) OldOrganizationID(ctx context.Context) (v uuid.UUID, return oldValue.OrganizationID, nil } +// ClearOrganizationID clears the value of the "organization_id" field. +func (m *APITokenMutation) ClearOrganizationID() { + m.organization = nil + m.clearedFields[apitoken.FieldOrganizationID] = struct{}{} +} + +// OrganizationIDCleared returns if the "organization_id" field was cleared in this mutation. +func (m *APITokenMutation) OrganizationIDCleared() bool { + _, ok := m.clearedFields[apitoken.FieldOrganizationID] + return ok +} + // ResetOrganizationID resets all changes to the "organization_id" field. func (m *APITokenMutation) ResetOrganizationID() { m.organization = nil + delete(m.clearedFields, apitoken.FieldOrganizationID) } // SetProjectID sets the "project_id" field. @@ -624,7 +637,7 @@ func (m *APITokenMutation) ClearOrganization() { // OrganizationCleared reports if the "organization" edge to the Organization entity was cleared. func (m *APITokenMutation) OrganizationCleared() bool { - return m.clearedorganization + return m.OrganizationIDCleared() || m.clearedorganization } // OrganizationIDs returns the "organization" edge IDs in the mutation. @@ -899,6 +912,9 @@ func (m *APITokenMutation) ClearedFields() []string { if m.FieldCleared(apitoken.FieldLastUsedAt) { fields = append(fields, apitoken.FieldLastUsedAt) } + if m.FieldCleared(apitoken.FieldOrganizationID) { + fields = append(fields, apitoken.FieldOrganizationID) + } if m.FieldCleared(apitoken.FieldProjectID) { fields = append(fields, apitoken.FieldProjectID) } @@ -931,6 +947,9 @@ func (m *APITokenMutation) ClearField(name string) error { case apitoken.FieldLastUsedAt: m.ClearLastUsedAt() return nil + case apitoken.FieldOrganizationID: + m.ClearOrganizationID() + return nil case apitoken.FieldProjectID: m.ClearProjectID() return nil diff --git a/app/controlplane/pkg/data/ent/schema/apitoken.go b/app/controlplane/pkg/data/ent/schema/apitoken.go index 53a3a9f04..ab4db2555 100644 --- a/app/controlplane/pkg/data/ent/schema/apitoken.go +++ b/app/controlplane/pkg/data/ent/schema/apitoken.go @@ -44,7 +44,8 @@ func (APIToken) Fields() []ent.Field { // the token can be manually revoked field.Time("revoked_at").Optional(), field.Time("last_used_at").Optional(), - field.UUID("organization_id", uuid.UUID{}), + // if this value is not set, the token is an instance-level token + 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 field.UUID("project_id", uuid.UUID{}).Optional(), @@ -56,7 +57,7 @@ func (APIToken) Fields() []ent.Field { func (APIToken) Edges() []ent.Edge { return []ent.Edge{ - edge.From("organization", Organization.Type).Field("organization_id").Ref("api_tokens").Unique().Required(), + edge.From("organization", Organization.Type).Field("organization_id").Ref("api_tokens").Unique(), edge.To("project", Project.Type).Field("project_id").Unique(), } } @@ -73,5 +74,10 @@ func (APIToken) Indexes() []ent.Index { index.Fields("name").Edges("project").Unique().Annotations( entsql.IndexWhere("revoked_at IS NULL AND project_id IS NOT NULL"), ), + + // for instance-level tokens, names must be unique across all instance tokens + index.Fields("name").Unique().Annotations( + entsql.IndexWhere("revoked_at IS NULL AND organization_id IS NULL"), + ), } } diff --git a/app/controlplane/pkg/jwt/apitoken/apitoken.go b/app/controlplane/pkg/jwt/apitoken/apitoken.go index a9be059bd..58c49bcba 100644 --- a/app/controlplane/pkg/jwt/apitoken/apitoken.go +++ b/app/controlplane/pkg/jwt/apitoken/apitoken.go @@ -68,13 +68,14 @@ func NewBuilder(opts ...NewOpt) (*Builder, error) { } type GenerateJWTOptions struct { - OrgID uuid.UUID - OrgName string + OrgID *uuid.UUID + OrgName *string KeyID uuid.UUID KeyName string ProjectID *uuid.UUID ProjectName *string ExpiresAt *time.Time + Scope *string } // GenerateJWT creates a new JWT token for the given organization and keyID @@ -83,14 +84,6 @@ func (ra *Builder) GenerateJWT(opts *GenerateJWTOptions) (string, error) { return "", errors.New("options are required") } - if opts.OrgID == uuid.Nil { - return "", errors.New("orgID is required") - } - - if opts.OrgName == "" { - return "", errors.New("orgName is required") - } - if opts.KeyID == uuid.Nil { return "", errors.New("keyID is required") } @@ -100,8 +93,6 @@ func (ra *Builder) GenerateJWT(opts *GenerateJWTOptions) (string, error) { } claims := CustomClaims{ - OrgID: opts.OrgID.String(), - OrgName: opts.OrgName, KeyName: opts.KeyName, RegisteredClaims: jwt.RegisteredClaims{ // Key identifier so we can check its revocation status @@ -111,6 +102,15 @@ func (ra *Builder) GenerateJWT(opts *GenerateJWTOptions) (string, error) { }, } + if opts.OrgID != nil { + claims.OrgID = opts.OrgID.String() + claims.OrgName = *opts.OrgName + } + + if opts.Scope != nil { + claims.Scope = *opts.Scope + } + if opts.ProjectID != nil { claims.ProjectID = opts.ProjectID.String() claims.ProjectName = *opts.ProjectName @@ -131,5 +131,6 @@ type CustomClaims struct { KeyName string `json:"token_name"` ProjectID string `json:"project_id,omitempty"` ProjectName string `json:"project_name,omitempty"` + Scope string `json:"scope,omitempty"` jwt.RegisteredClaims } diff --git a/app/controlplane/pkg/jwt/apitoken/apitoken_test.go b/app/controlplane/pkg/jwt/apitoken/apitoken_test.go index e04aaec39..1532be268 100644 --- a/app/controlplane/pkg/jwt/apitoken/apitoken_test.go +++ b/app/controlplane/pkg/jwt/apitoken/apitoken_test.go @@ -78,8 +78,8 @@ func TestGenerateJWT(t *testing.T) { { name: "no project", opts: &GenerateJWTOptions{ - OrgID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), - OrgName: "org-name", + OrgID: toPtr(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + OrgName: toPtr("org-name"), KeyName: "key-name", KeyID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), @@ -88,8 +88,8 @@ func TestGenerateJWT(t *testing.T) { { name: "no expiration", opts: &GenerateJWTOptions{ - OrgID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), - OrgName: "org-name", + OrgID: toPtr(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + OrgName: toPtr("org-name"), KeyName: "key-name", KeyID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), }, @@ -97,8 +97,8 @@ func TestGenerateJWT(t *testing.T) { { name: "with project", opts: &GenerateJWTOptions{ - OrgID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), - OrgName: "org-name", + OrgID: toPtr(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + OrgName: toPtr("org-name"), KeyName: "key-name", KeyID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), ProjectID: toPtr(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), @@ -107,30 +107,19 @@ func TestGenerateJWT(t *testing.T) { }, }, { - name: "missing orgID", + name: "instance token - no orgID or orgName", opts: &GenerateJWTOptions{ - OrgName: "org-name", KeyName: "key-name", KeyID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), + Scope: toPtr("INSTANCE_ADMIN"), }, - wantErr: true, - }, - { - name: "missing orgName", - opts: &GenerateJWTOptions{ - OrgID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), - KeyName: "key-name", - KeyID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), - ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), - }, - wantErr: true, }, { name: "missing keyID", opts: &GenerateJWTOptions{ - OrgID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), - OrgName: "org-name", + OrgID: toPtr(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + OrgName: toPtr("org-name"), KeyName: "key-name", ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), }, @@ -139,8 +128,8 @@ func TestGenerateJWT(t *testing.T) { { name: "missing keyName", opts: &GenerateJWTOptions{ - OrgID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), - OrgName: "org-name", + OrgID: toPtr(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), + OrgName: toPtr("org-name"), KeyID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), }, @@ -169,8 +158,18 @@ func TestGenerateJWT(t *testing.T) { require.NoError(t, err) assert.True(t, tokenInfo.Valid) - assert.Equal(t, tc.opts.OrgID.String(), claims.OrgID) - assert.Equal(t, tc.opts.OrgName, claims.OrgName) + + if tc.opts.OrgID != nil { + assert.Equal(t, tc.opts.OrgID.String(), claims.OrgID) + } else { + assert.Empty(t, claims.OrgID) + } + if tc.opts.OrgName != nil { + assert.Equal(t, *tc.opts.OrgName, claims.OrgName) + } else { + assert.Empty(t, claims.OrgName) + } + assert.Equal(t, tc.opts.KeyID.String(), claims.ID) assert.Equal(t, tc.opts.KeyName, claims.KeyName) @@ -182,6 +181,12 @@ func TestGenerateJWT(t *testing.T) { assert.Empty(t, claims.ProjectName) } + if tc.opts.Scope != nil { + assert.Equal(t, *tc.opts.Scope, claims.Scope) + } else { + assert.Empty(t, claims.Scope) + } + if tc.opts.ExpiresAt != nil { assert.True(t, claims.ExpiresAt.After(time.Now())) } else { From 60b4c7aebded58b01a097b19527d217d01d6de9a Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Tue, 13 Jan 2026 14:10:34 +0100 Subject: [PATCH 2/9] missing policies Signed-off-by: Sylwester Piskozub --- app/controlplane/pkg/authz/authz.go | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index bad0a6534..69e5adde8 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -109,6 +109,7 @@ var ( PolicyArtifactDownload = &Policy{ResourceCASArtifact, ActionRead} PolicyArtifactUpload = &Policy{ResourceCASArtifact, ActionCreate} // CAS backend + PolicyCASBackendCreate = &Policy{ResourceCASBackend, ActionCreate} PolicyCASBackendList = &Policy{ResourceCASBackend, ActionList} PolicyCASBackendUpdate = &Policy{ResourceCASBackend, ActionUpdate} // Available integrations @@ -149,10 +150,14 @@ var ( // Organization PolicyOrganizationCreate = &Policy{Organization, ActionCreate} + PolicyOrganizationUpdate = &Policy{Organization, ActionUpdate} PolicyOrganizationDelete = &Policy{Organization, ActionDelete} - // User Membership - PolicyOrganizationRead = &Policy{Organization, ActionRead} - PolicyOrganizationListMemberships = &Policy{OrganizationMemberships, ActionList} + PolicyOrganizationList = &Policy{Organization, ActionList} + PolicyOrganizationRead = &Policy{Organization, ActionRead} + // Organization Memberships + PolicyOrganizationListMemberships = &Policy{OrganizationMemberships, ActionList} + PolicyOrganizationMembershipDelete = &Policy{OrganizationMemberships, ActionDelete} + PolicyOrganizationMembershipUpdate = &Policy{OrganizationMemberships, ActionUpdate} // Group Memberships PolicyGroupListPendingInvitations = &Policy{ResourceGroup, ActionList} @@ -172,6 +177,8 @@ var ( PolicyProjectRemoveMemberships = &Policy{ResourceProjectMembership, ActionDelete} // Organization Invitations PolicyOrganizationInvitationsCreate = &Policy{ResourceOrganizationInvitations, ActionCreate} + PolicyOrganizationInvitationsList = &Policy{ResourceOrganizationInvitations, ActionList} + PolicyOrganizationInvitationsRevoke = &Policy{ResourceOrganizationInvitations, ActionDelete} ) // RolesMap The default list of policies for each role @@ -349,8 +356,10 @@ var ServerOperationsMap = map[string][]*Policy{ "/controlplane.v1.CASRedirectService/DownloadRedirect": {PolicyArtifactDownload}, // Or to retrieve a download url "/controlplane.v1.CASRedirectService/GetDownloadURL": {PolicyArtifactDownload}, - // CAS Backend listing + // CAS Backend + "/controlplane.v1.CASBackendService/Create": {PolicyCASBackendCreate}, "/controlplane.v1.CASBackendService/List": {PolicyCASBackendList}, + "/controlplane.v1.CASBackendService/Update": {PolicyCASBackendUpdate}, "/controlplane.v1.CASBackendService/Revalidate": {PolicyCASBackendUpdate}, // Available integrations "/controlplane.v1.IntegrationsService/ListAvailable": {PolicyAvailableIntegrationList, PolicyAvailableIntegrationRead}, @@ -388,12 +397,18 @@ var ServerOperationsMap = map[string][]*Policy{ // since all the permissions here are in the context of an organization // Create new organization "/controlplane.v1.OrganizationService/Create": {}, + "/controlplane.v1.OrganizationService/Update": {PolicyOrganizationUpdate}, // Delete an organization makes checks at the service level since the // user can explicitly set the org they want to delete and might not be the current one "/controlplane.v1.OrganizationService/Delete": {}, - - // List global memberships - "/controlplane.v1.OrganizationService/ListMemberships": {PolicyOrganizationListMemberships}, + // Organization memberships + "/controlplane.v1.OrganizationService/ListMemberships": {PolicyOrganizationListMemberships}, + "/controlplane.v1.OrganizationService/DeleteMembership": {PolicyOrganizationMembershipDelete}, + "/controlplane.v1.OrganizationService/UpdateMembership": {PolicyOrganizationMembershipUpdate}, + // Organization invitations + "/controlplane.v1.OrgInvitationService/Create": {PolicyOrganizationInvitationsCreate}, + "/controlplane.v1.OrgInvitationService/ListSent": {PolicyOrganizationInvitationsList}, + "/controlplane.v1.OrgInvitationService/Revoke": {PolicyOrganizationInvitationsRevoke}, // NOTE: this is about listing my own memberships, not about listing all the memberships in the organization "/controlplane.v1.UserService/ListMemberships": {}, From e66a5e772685427765bac4a5cc2703f8c2ae4037 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Tue, 13 Jan 2026 14:37:36 +0100 Subject: [PATCH 3/9] regenerate mocks Signed-off-by: Sylwester Piskozub --- .../pkg/biz/mocks/APITokenRepo.go | 32 +++++++++---------- .../pkg/biz/mocks/OrganizationRepo.go | 20 ++++++------ 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/app/controlplane/pkg/biz/mocks/APITokenRepo.go b/app/controlplane/pkg/biz/mocks/APITokenRepo.go index 2778cee47..74ae8c776 100644 --- a/app/controlplane/pkg/biz/mocks/APITokenRepo.go +++ b/app/controlplane/pkg/biz/mocks/APITokenRepo.go @@ -42,7 +42,7 @@ 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, policies []*authz.Policy) (*biz.APIToken, error) { +func (_mock *APITokenRepo) Create(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, policies []*authz.Policy) (*biz.APIToken, error) { ret := _mock.Called(ctx, name, description, expiresAt, organizationID, projectID, policies) if len(ret) == 0 { @@ -51,17 +51,17 @@ 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, []*authz.Policy) (*biz.APIToken, error)); ok { + if returnFunc, ok := ret.Get(0).(func(context.Context, string, *string, *time.Time, *uuid.UUID, *uuid.UUID, []*authz.Policy) (*biz.APIToken, error)); ok { return returnFunc(ctx, name, description, expiresAt, organizationID, projectID, policies) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, *string, *time.Time, uuid.UUID, *uuid.UUID, []*authz.Policy) *biz.APIToken); ok { + if returnFunc, ok := ret.Get(0).(func(context.Context, string, *string, *time.Time, *uuid.UUID, *uuid.UUID, []*authz.Policy) *biz.APIToken); ok { r0 = returnFunc(ctx, name, description, expiresAt, organizationID, projectID, policies) } 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, []*authz.Policy) error); ok { + if returnFunc, ok := ret.Get(1).(func(context.Context, string, *string, *time.Time, *uuid.UUID, *uuid.UUID, []*authz.Policy) error); ok { r1 = returnFunc(ctx, name, description, expiresAt, organizationID, projectID, policies) } else { r1 = ret.Error(1) @@ -79,14 +79,14 @@ type APITokenRepo_Create_Call struct { // - name string // - description *string // - expiresAt *time.Time -// - organizationID uuid.UUID +// - organizationID *uuid.UUID // - projectID *uuid.UUID // - policies []*authz.Policy func (_e *APITokenRepo_Expecter) Create(ctx interface{}, name interface{}, description interface{}, expiresAt interface{}, organizationID interface{}, projectID interface{}, policies interface{}) *APITokenRepo_Create_Call { return &APITokenRepo_Create_Call{Call: _e.mock.On("Create", ctx, name, description, expiresAt, organizationID, projectID, policies)} } -func (_c *APITokenRepo_Create_Call) Run(run func(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID uuid.UUID, projectID *uuid.UUID, policies []*authz.Policy)) *APITokenRepo_Create_Call { +func (_c *APITokenRepo_Create_Call) Run(run func(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, policies []*authz.Policy)) *APITokenRepo_Create_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -104,9 +104,9 @@ func (_c *APITokenRepo_Create_Call) Run(run func(ctx context.Context, name strin if args[3] != nil { arg3 = args[3].(*time.Time) } - var arg4 uuid.UUID + var arg4 *uuid.UUID if args[4] != nil { - arg4 = args[4].(uuid.UUID) + arg4 = args[4].(*uuid.UUID) } var arg5 *uuid.UUID if args[5] != nil { @@ -134,7 +134,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, policies []*authz.Policy) (*biz.APIToken, error)) *APITokenRepo_Create_Call { +func (_c *APITokenRepo_Create_Call) RunAndReturn(run func(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, policies []*authz.Policy) (*biz.APIToken, error)) *APITokenRepo_Create_Call { _c.Call.Return(run) return _c } @@ -430,7 +430,7 @@ func (_c *APITokenRepo_List_Call) RunAndReturn(run func(ctx context.Context, org } // Revoke provides a mock function for the type APITokenRepo -func (_mock *APITokenRepo) Revoke(ctx context.Context, orgID uuid.UUID, ID uuid.UUID) error { +func (_mock *APITokenRepo) Revoke(ctx context.Context, orgID *uuid.UUID, ID uuid.UUID) error { ret := _mock.Called(ctx, orgID, ID) if len(ret) == 0 { @@ -438,7 +438,7 @@ func (_mock *APITokenRepo) Revoke(ctx context.Context, orgID uuid.UUID, ID uuid. } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID) error); ok { + if returnFunc, ok := ret.Get(0).(func(context.Context, *uuid.UUID, uuid.UUID) error); ok { r0 = returnFunc(ctx, orgID, ID) } else { r0 = ret.Error(0) @@ -453,21 +453,21 @@ type APITokenRepo_Revoke_Call struct { // Revoke is a helper method to define mock.On call // - ctx context.Context -// - orgID uuid.UUID +// - orgID *uuid.UUID // - ID uuid.UUID func (_e *APITokenRepo_Expecter) Revoke(ctx interface{}, orgID interface{}, ID interface{}) *APITokenRepo_Revoke_Call { return &APITokenRepo_Revoke_Call{Call: _e.mock.On("Revoke", ctx, orgID, ID)} } -func (_c *APITokenRepo_Revoke_Call) Run(run func(ctx context.Context, orgID uuid.UUID, ID uuid.UUID)) *APITokenRepo_Revoke_Call { +func (_c *APITokenRepo_Revoke_Call) Run(run func(ctx context.Context, orgID *uuid.UUID, ID uuid.UUID)) *APITokenRepo_Revoke_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } - var arg1 uuid.UUID + var arg1 *uuid.UUID if args[1] != nil { - arg1 = args[1].(uuid.UUID) + arg1 = args[1].(*uuid.UUID) } var arg2 uuid.UUID if args[2] != nil { @@ -487,7 +487,7 @@ func (_c *APITokenRepo_Revoke_Call) Return(err error) *APITokenRepo_Revoke_Call return _c } -func (_c *APITokenRepo_Revoke_Call) RunAndReturn(run func(ctx context.Context, orgID uuid.UUID, ID uuid.UUID) error) *APITokenRepo_Revoke_Call { +func (_c *APITokenRepo_Revoke_Call) RunAndReturn(run func(ctx context.Context, orgID *uuid.UUID, ID uuid.UUID) error) *APITokenRepo_Revoke_Call { _c.Call.Return(run) return _c } diff --git a/app/controlplane/pkg/biz/mocks/OrganizationRepo.go b/app/controlplane/pkg/biz/mocks/OrganizationRepo.go index 56e67e83b..aac07d8a6 100644 --- a/app/controlplane/pkg/biz/mocks/OrganizationRepo.go +++ b/app/controlplane/pkg/biz/mocks/OrganizationRepo.go @@ -301,8 +301,8 @@ func (_c *OrganizationRepo_FindByName_Call) RunAndReturn(run func(ctx context.Co } // Update provides a mock function for the type OrganizationRepo -func (_mock *OrganizationRepo) Update(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, preventProjectScopedContracts *bool) (*biz.Organization, error) { - ret := _mock.Called(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, preventProjectScopedContracts) +func (_mock *OrganizationRepo) Update(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool) (*biz.Organization, error) { + ret := _mock.Called(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins) if len(ret) == 0 { panic("no return value specified for Update") @@ -311,17 +311,17 @@ func (_mock *OrganizationRepo) Update(ctx context.Context, id uuid.UUID, blockOn var r0 *biz.Organization var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, *bool, []string, *bool, *bool) (*biz.Organization, error)); ok { - return returnFunc(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, preventProjectScopedContracts) + return returnFunc(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins) } if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, *bool, []string, *bool, *bool) *biz.Organization); ok { - r0 = returnFunc(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, preventProjectScopedContracts) + r0 = returnFunc(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*biz.Organization) } } if returnFunc, ok := ret.Get(1).(func(context.Context, uuid.UUID, *bool, []string, *bool, *bool) error); ok { - r1 = returnFunc(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, preventProjectScopedContracts) + r1 = returnFunc(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins) } else { r1 = ret.Error(1) } @@ -339,12 +339,12 @@ type OrganizationRepo_Update_Call struct { // - blockOnPolicyViolation *bool // - policiesAllowedHostnames []string // - preventImplicitWorkflowCreation *bool -// - preventProjectScopedContracts *bool -func (_e *OrganizationRepo_Expecter) Update(ctx interface{}, id interface{}, blockOnPolicyViolation interface{}, policiesAllowedHostnames interface{}, preventImplicitWorkflowCreation interface{}, preventProjectScopedContracts interface{}) *OrganizationRepo_Update_Call { - return &OrganizationRepo_Update_Call{Call: _e.mock.On("Update", ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, preventProjectScopedContracts)} +// - restrictContractCreationToOrgAdmins *bool +func (_e *OrganizationRepo_Expecter) Update(ctx interface{}, id interface{}, blockOnPolicyViolation interface{}, policiesAllowedHostnames interface{}, preventImplicitWorkflowCreation interface{}, restrictContractCreationToOrgAdmins interface{}) *OrganizationRepo_Update_Call { + return &OrganizationRepo_Update_Call{Call: _e.mock.On("Update", ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins)} } -func (_c *OrganizationRepo_Update_Call) Run(run func(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, preventProjectScopedContracts *bool)) *OrganizationRepo_Update_Call { +func (_c *OrganizationRepo_Update_Call) Run(run func(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool)) *OrganizationRepo_Update_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -387,7 +387,7 @@ func (_c *OrganizationRepo_Update_Call) Return(organization *biz.Organization, e return _c } -func (_c *OrganizationRepo_Update_Call) RunAndReturn(run func(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, preventProjectScopedContracts *bool) (*biz.Organization, error)) *OrganizationRepo_Update_Call { +func (_c *OrganizationRepo_Update_Call) RunAndReturn(run func(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool) (*biz.Organization, error)) *OrganizationRepo_Update_Call { _c.Call.Return(run) return _c } From 50253b9ee338d0e86417bbd21e442f9c15908dbf Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Tue, 13 Jan 2026 14:44:13 +0100 Subject: [PATCH 4/9] migration fix Signed-off-by: Sylwester Piskozub --- .../pkg/data/ent/migrate/migrations/20260112115927.sql | 4 ++++ app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260112115927.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260112115927.sql index 44fe5639f..e8856d170 100644 --- a/app/controlplane/pkg/data/ent/migrate/migrations/20260112115927.sql +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260112115927.sql @@ -1,2 +1,6 @@ -- Make organization_id nullable to support instance-level API tokens ALTER TABLE "api_tokens" ALTER COLUMN "organization_id" DROP NOT NULL; + +-- Create index "apitoken_name" to table: "api_tokens" +CREATE UNIQUE INDEX "apitoken_name" ON "api_tokens" ("name") WHERE ((revoked_at IS NULL) AND (organization_id IS NULL)); + diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index 602c4c093..80dc1e7eb 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:u4YpAZJKkuQougTxmCKQwjwmksDwPdohzZeGTAB6cg8= +h1:Xgi8kkxc2dgEdAYSi1CmSXcczC+eiY2CIURHYEjCH3c= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -122,4 +122,4 @@ h1:u4YpAZJKkuQougTxmCKQwjwmksDwPdohzZeGTAB6cg8= 20251114174059.sql h1:f/wB/OlhZxIc9AVCxTNu4dFmPd1T3sCY0nS8Zb9ZS9Q= 20251212115308.sql h1:CmwHDA9X91++2dnThzk57++5sBDAGw2IQnHzO3/bRlk= 20251217164302.sql h1:OL3OCqWsMtv06RfIlQNcdLMbt4Tz91Lijpbkxqwt7zM= -20260112115927.sql h1:OHbuh0pnXDm+udI1eyRwswP6YlGC99+5DSFQNXwGjZg= +20260112115927.sql h1:/RKhzT5dRphgeBitxBfo3a3fqLVgvmVZxxqe9fH8lkg= From 72e92d926458bfcd69a46498e8c1fa2b7de4dfdd Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Tue, 13 Jan 2026 14:53:38 +0100 Subject: [PATCH 5/9] fix test Signed-off-by: Sylwester Piskozub --- .../pkg/biz/apitoken_integration_test.go | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/app/controlplane/pkg/biz/apitoken_integration_test.go b/app/controlplane/pkg/biz/apitoken_integration_test.go index 5809eccc7..d346446f8 100644 --- a/app/controlplane/pkg/biz/apitoken_integration_test.go +++ b/app/controlplane/pkg/biz/apitoken_integration_test.go @@ -38,14 +38,14 @@ func randomName() string { func (s *apiTokenTestSuite) TestCreate() { ctx := context.Background() s.Run("invalid org ID", func() { - token, err := s.APIToken.Create(ctx, randomName(), nil, nil, "deadbeef") + token, err := s.APIToken.Create(ctx, randomName(), nil, nil, toPtrS("deadbeef")) s.Error(err) s.True(biz.IsErrInvalidUUID(err)) s.Nil(token) }) s.Run("happy path without expiration nor description", func() { - token, err := s.APIToken.Create(ctx, randomName(), nil, nil, s.org.ID) + token, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID) s.NoError(err) s.NotNil(token.ID) s.Equal(s.org.ID, token.OrganizationID.String()) @@ -56,7 +56,7 @@ func (s *apiTokenTestSuite) TestCreate() { }) s.Run("happy path with description and expiration", func() { - token, err := s.APIToken.Create(ctx, randomName(), toPtrS("tokenStr"), toPtrDuration(24*time.Hour), s.org.ID) + token, err := s.APIToken.Create(ctx, randomName(), toPtrS("tokenStr"), toPtrDuration(24*time.Hour), &s.org.ID) s.NoError(err) s.Equal(s.org.ID, token.OrganizationID.String()) s.Equal("tokenStr", token.Description) @@ -65,7 +65,7 @@ func (s *apiTokenTestSuite) TestCreate() { }) s.Run("happy path with project", func() { - token, err := s.APIToken.Create(ctx, randomName(), nil, nil, s.org.ID, biz.APITokenWithProject(s.p1)) + token, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID, biz.APITokenWithProject(s.p1)) s.NoError(err) s.Equal(s.org.ID, token.OrganizationID.String()) s.Equal(s.p1.ID, *token.ProjectID) @@ -128,7 +128,7 @@ func (s *apiTokenTestSuite) TestCreate() { opts = append(opts, biz.APITokenWithProject(tc.project)) } - token, err := s.APIToken.Create(ctx, tc.tokenName, nil, nil, s.org.ID, opts...) + token, err := s.APIToken.Create(ctx, tc.tokenName, nil, nil, &s.org.ID, opts...) if tc.wantErrMsg != "" { s.Error(err) s.Contains(err.Error(), tc.wantErrMsg) @@ -145,7 +145,7 @@ func (s *apiTokenTestSuite) TestCreate() { func (s *apiTokenTestSuite) TestAuthzPolicies() { // a new token has a new set of policies associated - token, err := s.APIToken.Create(context.Background(), randomName(), nil, nil, s.org.ID) + token, err := s.APIToken.Create(context.Background(), randomName(), nil, nil, &s.org.ID) require.NoError(s.T(), err) // With the new architecture, API token policies are stored in the database, not in Casbin @@ -308,7 +308,7 @@ func (s *apiTokenTestSuite) TestList() { } func (s *apiTokenTestSuite) TestGeneratedJWT() { - token, err := s.APIToken.Create(context.Background(), randomName(), nil, toPtrDuration(24*time.Hour), s.org.ID) + token, err := s.APIToken.Create(context.Background(), randomName(), nil, toPtrDuration(24*time.Hour), &s.org.ID) s.NoError(err) require.NotNil(s.T(), token) @@ -358,22 +358,22 @@ func (s *apiTokenTestSuite) SetupTest() { require.NoError(s.T(), err) // Create 2 tokens for org 1 - s.t1, err = s.APIToken.Create(ctx, randomName(), nil, nil, s.org.ID) + s.t1, err = s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID) require.NoError(s.T(), err) - s.t2, err = s.APIToken.Create(ctx, randomName(), nil, nil, s.org.ID) + s.t2, err = s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID) require.NoError(s.T(), err) // and 1 token for org 2 - s.t3, err = s.APIToken.Create(ctx, randomName(), nil, nil, s.org2.ID) + s.t3, err = s.APIToken.Create(ctx, randomName(), nil, nil, &s.org2.ID) require.NoError(s.T(), err) // Create 2 tokens for project 1 - s.t4, err = s.APIToken.Create(ctx, randomName(), nil, nil, s.org.ID, biz.APITokenWithProject(s.p1)) + s.t4, err = s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID, biz.APITokenWithProject(s.p1)) require.NoError(s.T(), err) - s.t5, err = s.APIToken.Create(ctx, randomName(), nil, nil, s.org.ID, biz.APITokenWithProject(s.p1)) + s.t5, err = s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID, biz.APITokenWithProject(s.p1)) require.NoError(s.T(), err) // Create 1 token for project 2 - s.t6, err = s.APIToken.Create(ctx, randomName(), nil, nil, s.org.ID, biz.APITokenWithProject(s.p2)) + s.t6, err = s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID, biz.APITokenWithProject(s.p2)) require.NoError(s.T(), err) } @@ -381,7 +381,7 @@ func (s *apiTokenTestSuite) TestUpdateLastUsedAt() { ctx := context.Background() s.Run("update last used at", func() { - token, err := s.APIToken.Create(ctx, randomName(), nil, nil, s.org.ID) + token, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID) s.NoError(err) s.Nil(token.LastUsedAt) @@ -407,7 +407,7 @@ func (s *apiTokenTestSuite) TestUpdateLastUsedAt() { }) s.Run("token is revoked", func() { - token, err := s.APIToken.Create(ctx, randomName(), nil, nil, s.org.ID) + token, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID) s.NoError(err) err = s.APIToken.Revoke(ctx, s.org.ID, token.ID.String()) s.NoError(err) From e2adba208384c79f9c87819476e1166b12dbba1d Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Tue, 13 Jan 2026 14:58:05 +0100 Subject: [PATCH 6/9] lint Signed-off-by: Sylwester Piskozub --- app/controlplane/pkg/authz/authz.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index 69e5adde8..4e5df0c46 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -155,9 +155,9 @@ var ( PolicyOrganizationList = &Policy{Organization, ActionList} PolicyOrganizationRead = &Policy{Organization, ActionRead} // Organization Memberships - PolicyOrganizationListMemberships = &Policy{OrganizationMemberships, ActionList} - PolicyOrganizationMembershipDelete = &Policy{OrganizationMemberships, ActionDelete} - PolicyOrganizationMembershipUpdate = &Policy{OrganizationMemberships, ActionUpdate} + PolicyOrganizationListMemberships = &Policy{OrganizationMemberships, ActionList} + PolicyOrganizationMembershipDelete = &Policy{OrganizationMemberships, ActionDelete} + PolicyOrganizationMembershipUpdate = &Policy{OrganizationMemberships, ActionUpdate} // Group Memberships PolicyGroupListPendingInvitations = &Policy{ResourceGroup, ActionList} @@ -402,7 +402,7 @@ var ServerOperationsMap = map[string][]*Policy{ // user can explicitly set the org they want to delete and might not be the current one "/controlplane.v1.OrganizationService/Delete": {}, // Organization memberships - "/controlplane.v1.OrganizationService/ListMemberships": {PolicyOrganizationListMemberships}, + "/controlplane.v1.OrganizationService/ListMemberships": {PolicyOrganizationListMemberships}, "/controlplane.v1.OrganizationService/DeleteMembership": {PolicyOrganizationMembershipDelete}, "/controlplane.v1.OrganizationService/UpdateMembership": {PolicyOrganizationMembershipUpdate}, // Organization invitations From 788728d84e65725b7515a49ecc2e1ad944a2af44 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Tue, 13 Jan 2026 20:45:22 +0100 Subject: [PATCH 7/9] allow empty org in list Signed-off-by: Sylwester Piskozub --- app/controlplane/pkg/biz/apitoken.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index 2f9faea28..3cd4793cf 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -352,12 +352,16 @@ func (uc *APITokenUseCase) List(ctx context.Context, orgID string, opts ...APITo return nil, NewErrValidationStr(fmt.Sprintf("invalid scope %q, please chose one of: %v", filters.FilterByScope, availableAPITokenScopes)) } - orgUUID, err := uuid.Parse(orgID) - if err != nil { - return nil, NewErrInvalidUUID(err) + var orgUUID *uuid.UUID + if orgID != "" { + parsed, err := uuid.Parse(orgID) + if err != nil { + return nil, NewErrInvalidUUID(err) + } + orgUUID = &parsed } - return uc.apiTokenRepo.List(ctx, &orgUUID, filters) + return uc.apiTokenRepo.List(ctx, orgUUID, filters) } func (uc *APITokenUseCase) Revoke(ctx context.Context, orgID, id string) error { From 3ffcdf2d3ffc65ab62888f2a45abfaef93a8a8bd Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Wed, 14 Jan 2026 09:23:11 +0100 Subject: [PATCH 8/9] remove policies Signed-off-by: Sylwester Piskozub --- app/controlplane/pkg/authz/authz.go | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index 4e5df0c46..bad0a6534 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -109,7 +109,6 @@ var ( PolicyArtifactDownload = &Policy{ResourceCASArtifact, ActionRead} PolicyArtifactUpload = &Policy{ResourceCASArtifact, ActionCreate} // CAS backend - PolicyCASBackendCreate = &Policy{ResourceCASBackend, ActionCreate} PolicyCASBackendList = &Policy{ResourceCASBackend, ActionList} PolicyCASBackendUpdate = &Policy{ResourceCASBackend, ActionUpdate} // Available integrations @@ -150,14 +149,10 @@ var ( // Organization PolicyOrganizationCreate = &Policy{Organization, ActionCreate} - PolicyOrganizationUpdate = &Policy{Organization, ActionUpdate} PolicyOrganizationDelete = &Policy{Organization, ActionDelete} - PolicyOrganizationList = &Policy{Organization, ActionList} - PolicyOrganizationRead = &Policy{Organization, ActionRead} - // Organization Memberships - PolicyOrganizationListMemberships = &Policy{OrganizationMemberships, ActionList} - PolicyOrganizationMembershipDelete = &Policy{OrganizationMemberships, ActionDelete} - PolicyOrganizationMembershipUpdate = &Policy{OrganizationMemberships, ActionUpdate} + // User Membership + PolicyOrganizationRead = &Policy{Organization, ActionRead} + PolicyOrganizationListMemberships = &Policy{OrganizationMemberships, ActionList} // Group Memberships PolicyGroupListPendingInvitations = &Policy{ResourceGroup, ActionList} @@ -177,8 +172,6 @@ var ( PolicyProjectRemoveMemberships = &Policy{ResourceProjectMembership, ActionDelete} // Organization Invitations PolicyOrganizationInvitationsCreate = &Policy{ResourceOrganizationInvitations, ActionCreate} - PolicyOrganizationInvitationsList = &Policy{ResourceOrganizationInvitations, ActionList} - PolicyOrganizationInvitationsRevoke = &Policy{ResourceOrganizationInvitations, ActionDelete} ) // RolesMap The default list of policies for each role @@ -356,10 +349,8 @@ var ServerOperationsMap = map[string][]*Policy{ "/controlplane.v1.CASRedirectService/DownloadRedirect": {PolicyArtifactDownload}, // Or to retrieve a download url "/controlplane.v1.CASRedirectService/GetDownloadURL": {PolicyArtifactDownload}, - // CAS Backend - "/controlplane.v1.CASBackendService/Create": {PolicyCASBackendCreate}, + // CAS Backend listing "/controlplane.v1.CASBackendService/List": {PolicyCASBackendList}, - "/controlplane.v1.CASBackendService/Update": {PolicyCASBackendUpdate}, "/controlplane.v1.CASBackendService/Revalidate": {PolicyCASBackendUpdate}, // Available integrations "/controlplane.v1.IntegrationsService/ListAvailable": {PolicyAvailableIntegrationList, PolicyAvailableIntegrationRead}, @@ -397,18 +388,12 @@ var ServerOperationsMap = map[string][]*Policy{ // since all the permissions here are in the context of an organization // Create new organization "/controlplane.v1.OrganizationService/Create": {}, - "/controlplane.v1.OrganizationService/Update": {PolicyOrganizationUpdate}, // Delete an organization makes checks at the service level since the // user can explicitly set the org they want to delete and might not be the current one "/controlplane.v1.OrganizationService/Delete": {}, - // Organization memberships - "/controlplane.v1.OrganizationService/ListMemberships": {PolicyOrganizationListMemberships}, - "/controlplane.v1.OrganizationService/DeleteMembership": {PolicyOrganizationMembershipDelete}, - "/controlplane.v1.OrganizationService/UpdateMembership": {PolicyOrganizationMembershipUpdate}, - // Organization invitations - "/controlplane.v1.OrgInvitationService/Create": {PolicyOrganizationInvitationsCreate}, - "/controlplane.v1.OrgInvitationService/ListSent": {PolicyOrganizationInvitationsList}, - "/controlplane.v1.OrgInvitationService/Revoke": {PolicyOrganizationInvitationsRevoke}, + + // List global memberships + "/controlplane.v1.OrganizationService/ListMemberships": {PolicyOrganizationListMemberships}, // NOTE: this is about listing my own memberships, not about listing all the memberships in the organization "/controlplane.v1.UserService/ListMemberships": {}, From 5a382863ec1cd6b74a3260511f4d894c6428555d Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Wed, 14 Jan 2026 09:24:12 +0100 Subject: [PATCH 9/9] fix audit Signed-off-by: Sylwester Piskozub --- app/controlplane/pkg/biz/apitoken.go | 32 ++++++++++++---------------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index 3cd4793cf..3f737f08d 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -237,16 +237,14 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description } // Dispatch the event to the auditor to notify the creation of the token - if orgUUID != nil { - uc.auditorUC.Dispatch(ctx, &events.APITokenCreated{ - APITokenBase: &events.APITokenBase{ - APITokenID: &token.ID, - APITokenName: name, - }, - APITokenDescription: description, - ExpiresAt: expiresAt, - }, orgUUID) - } + uc.auditorUC.Dispatch(ctx, &events.APITokenCreated{ + APITokenBase: &events.APITokenBase{ + APITokenID: &token.ID, + APITokenName: name, + }, + APITokenDescription: description, + ExpiresAt: expiresAt, + }, orgUUID) return token, nil } @@ -389,14 +387,12 @@ func (uc *APITokenUseCase) Revoke(ctx context.Context, orgID, id string) error { } // Dispatch the event to the auditor to notify the revocation of the token - if orgUUID != nil { - uc.auditorUC.Dispatch(ctx, &events.APITokenRevoked{ - APITokenBase: &events.APITokenBase{ - APITokenID: &tokenUUID, - APITokenName: token.Name, - }, - }, orgUUID) - } + uc.auditorUC.Dispatch(ctx, &events.APITokenRevoked{ + APITokenBase: &events.APITokenBase{ + APITokenID: &tokenUUID, + APITokenName: token.Name, + }, + }, orgUUID) return nil }