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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controlplane/internal/service/apitoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, &currentOrg.ID, biz.APITokenWithProject(project))
if err != nil {
return nil, handleUseCaseErr(err, s.log)
}
Expand Down
47 changes: 35 additions & 12 deletions app/controlplane/internal/usercontext/apitoken_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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")
}
Expand All @@ -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(),
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/controlplane/internal/usercontext/entities/apitoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions app/controlplane/pkg/authz/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
110 changes: 75 additions & 35 deletions app/controlplane/pkg/biz/apitoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -131,6 +131,7 @@ func NewAPITokenUseCase(apiTokenRepo APITokenRepo, jwtConfig *APITokenJWTConfig,
type apiTokenOptions struct {
project *Project
showOnlySystemTokens bool
policies []*authz.Policy
}

type APITokenCreateOpt func(*apiTokenOptions)
Expand All @@ -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 == "" {
Expand All @@ -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")
Expand All @@ -194,21 +212,26 @@ 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)
}

// generate the JWT
token.JWT, err = uc.jwtBuilder.GenerateJWT(generationOpts)

if err != nil {
return nil, fmt.Errorf("generating jwt: %w", err)
}
Expand All @@ -221,7 +244,7 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description
},
APITokenDescription: description,
ExpiresAt: expiresAt,
}, &orgUUID)
}, orgUUID)

return token, nil
}
Expand All @@ -239,19 +262,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 {
Expand Down Expand Up @@ -289,13 +319,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 {
Expand All @@ -318,18 +350,26 @@ 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 {
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)
Expand All @@ -352,7 +392,7 @@ func (uc *APITokenUseCase) Revoke(ctx context.Context, orgID, id string) error {
APITokenID: &tokenUUID,
APITokenName: token.Name,
},
}, &orgUUID)
}, orgUUID)

return nil
}
Expand Down
Loading
Loading