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 Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ TAG := $(shell git rev-list --tags --max-count=1)
VERSION := $(shell git describe --tags ${TAG})
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto admin-app compose-up-dev
.DEFAULT_GOAL := build
PROTON_COMMIT := "8c639b3bcf2c458cb42dea00374045d6b9b4d0e9"
PROTON_COMMIT := "06620262d6c0e850b1e97ace2bebdbbfc5b5ed51"

admin-app:
@echo " > generating admin build"
Expand Down
57 changes: 57 additions & 0 deletions core/userpat/mocks/repository.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

97 changes: 97 additions & 0 deletions core/userpat/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,103 @@ func (s *Service) Delete(ctx context.Context, userID, id string) error {
return nil
}

// Update replaces a PAT's title, metadata, and scope (roles + projects).
// Scope changes use revoke-all + recreate pattern with a TOCTOU guard
// against concurrent Delete.
func (s *Service) Update(ctx context.Context, toUpdate patmodels.PAT) (patmodels.PAT, error) {
if !s.config.Enabled {
return patmodels.PAT{}, paterrors.ErrDisabled
}

existing, err := s.getOwnedPAT(ctx, toUpdate.UserID, toUpdate.ID)
if err != nil {
return patmodels.PAT{}, err
}

roles, err := s.resolveAndValidateRoles(ctx, toUpdate.RoleIDs)
if err != nil {
return patmodels.PAT{}, err
}

oldTitle, oldRoleIDs, oldProjectIDs, err := s.captureOldScope(ctx, &existing)
if err != nil {
return patmodels.PAT{}, err
}

updated, err := s.repo.Update(ctx, patmodels.PAT{
ID: toUpdate.ID,
Title: toUpdate.Title,
Metadata: toUpdate.Metadata,
})
if err != nil {
return patmodels.PAT{}, fmt.Errorf("updating PAT: %w", err)
}
Comment thread
AmanGIT07 marked this conversation as resolved.

if err := s.replacePolicies(ctx, toUpdate.ID, existing.OrgID, roles, toUpdate.ProjectIDs); err != nil {
return patmodels.PAT{}, err
}

if err := s.enrichWithScope(ctx, &updated); err != nil {
return patmodels.PAT{}, fmt.Errorf("enriching PAT scope: %w", err)
}

s.auditUpdate(ctx, updated, toUpdate, oldTitle, oldRoleIDs, oldProjectIDs)

return updated, nil
}

// getOwnedPAT retrieves a PAT and verifies it belongs to the given user.
func (s *Service) getOwnedPAT(ctx context.Context, userID, id string) (patmodels.PAT, error) {
pat, err := s.repo.GetByID(ctx, id)
if err != nil {
return patmodels.PAT{}, err
}
if pat.UserID != userID {
return patmodels.PAT{}, paterrors.ErrNotFound
}
return pat, nil
}

// captureOldScope enriches the PAT with its current scope and returns old title, role IDs, and project IDs for audit.
func (s *Service) captureOldScope(ctx context.Context, pat *patmodels.PAT) (string, []string, []string, error) {
oldTitle := pat.Title
if err := s.enrichWithScope(ctx, pat); err != nil {
return "", nil, nil, fmt.Errorf("enriching old PAT scope: %w", err)
}
return oldTitle, pat.RoleIDs, pat.ProjectIDs, nil
}

// replacePolicies deletes existing policies and creates new ones.
// Re-checks PAT existence after delete to guard against concurrent soft-delete.
func (s *Service) replacePolicies(ctx context.Context, patID, orgID string, roles []role.Role, projectIDs []string) error {
if err := s.deletePolicies(ctx, patID); err != nil {
return fmt.Errorf("deleting old policies: %w", err)
}

// TOCTOU guard: ensure PAT wasn't soft-deleted by concurrent Delete.
if _, err := s.repo.GetByID(ctx, patID); err != nil {
return fmt.Errorf("PAT deleted concurrently: %w", err)
}

if err := s.createPolicies(ctx, patID, orgID, roles, projectIDs); err != nil {
return fmt.Errorf("creating new policies: %w", err)
}
return nil
}

// auditUpdate creates an audit record for the PAT update. Errors are logged, not returned.
func (s *Service) auditUpdate(ctx context.Context, updated patmodels.PAT, toUpdate patmodels.PAT, oldTitle string, oldRoleIDs []string, oldProjectIDs []string) {
if err := s.createAuditRecord(ctx, pkgAuditRecord.PATUpdatedEvent, updated, time.Now().UTC(), map[string]any{
"role_ids": toUpdate.RoleIDs,
"project_ids": toUpdate.ProjectIDs,
"old_title": oldTitle,
"old_role_ids": oldRoleIDs,
"old_project_ids": oldProjectIDs,
}); err != nil {
s.logger.Error("failed to create audit record for PAT update", "pat_id", toUpdate.ID, "error", err)
}
}

// deletePolicies removes all SpiceDB policies associated with a PAT.
// Each policy.Delete call removes SpiceDB relations first, then hard-deletes the Postgres policy row.
func (s *Service) deletePolicies(ctx context.Context, patID string) error {
Expand Down
Loading
Loading