Skip to content

fix: resolve PAT to user for resource/project ownership and block PAT superuser access#1551

Open
AmanGIT07 wants to merge 3 commits intomainfrom
fix/resolve-pat-to-user
Open

fix: resolve PAT to user for resource/project ownership and block PAT superuser access#1551
AmanGIT07 wants to merge 3 commits intomainfrom
fix/resolve-pat-to-user

Conversation

@AmanGIT07
Copy link
Copy Markdown
Contributor

Summary

  • PAT principal (app/pat) was used directly to create ownership policies, relations for resources, where only app/user or app/serviceuser are allowed
  • Resolves PAT → underlying user via ResolveSubject() before creating ownership artifacts
  • Adds resource.created audit event with actor from auth context (PAT ID + user metadata preserved in actor)
  • PATs are now explicitly denied superuser status in IsSuperUser — scoped tokens should not bypass authz

Fixes

Location Issue Fix
core/resource/service.go Create PAT ID stored as resource principal + SpiceDB owner Resolve to user before Postgres insert and owner relation
core/project/service.go Create PAT ID as project owner policy ResolveSubject() before policy creation
authorize.go IsSuperUser PAT fell through to serviceUser sudo check Explicit PAT case → PermissionDenied
authorize.go IntrospectPolicy Self-introspection compared with principal.ID PAT can self-introspect by passing PAT ID as user_id

Audit record

  • Added resource.created event constant
  • Resource service now creates audit record on resource creation
  • Actor (PAT or user) captured automatically from auth interceptor context
  • Target: resource ID, namespace, name

Tests

  • go build ./... passes
  • make lint-fix — 0 issues
  • 13 new tests for resource service: Get, Create (user, PAT, explicit principal), List, Update, Delete, AddProjectToResource, AddResourceOwner
  • All existing tests pass

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview, Comment Apr 18, 2026 3:23pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 17, 2026

📝 Walkthrough

Summary by CodeRabbit

Release Notes

  • New Features

    • Audit records are now automatically created when resources are created.
    • Improved authorization handling with explicit checks for Personal Access Token principals.
  • Bug Fixes

    • Enhanced principal resolution logic to properly identify underlying subjects for policy ownership.
  • Tests

    • Expanded test coverage for resource operations and authorization scenarios.

Walkthrough

Added audit record creation to the resource service with PAT principal resolution. Updated resource service constructor to accept an audit record repository, integrated audit record creation after resource creation, modified PAT principal handling to resolve to underlying users, adjusted project service to use resolved principal subjects, and updated authorization checks for PAT principals.

Changes

Cohort / File(s) Summary
Core Resource Service Enhancements
core/resource/service.go, core/resource/mocks/audit_record_repository.go
Extended resource service with audit record repository dependency, PAT principal resolution via resolvePATUser(), and audit record creation after resource setup. Added AuditRecordRepository interface and autogenerated mock implementation for testing.
Resource Service Tests
core/resource/service_test.go
Updated test helper to inject audit repository mock and added comprehensive unit tests for Get, Create, List, Update, Delete, AddProjectToResource, and AddResourceOwner methods with assertions on delegation and principal resolution behavior.
Principal and Authorization Updates
core/project/service.go, internal/api/v1beta1connect/authorize.go
Modified project service Create to resolve authenticated principal via ResolveSubject() for owner policy. Refactored IsSuperUser authorization handler to explicitly check PAT principals (denying access) and service user principals with dedicated branches.
Dependency Wiring
cmd/serve.go
Updated buildAPIDependencies to pass auditRecordRepository into resource service constructor.
Audit Event Constants
pkg/auditrecord/consts.go
Added ResourceCreatedEvent constant for audit record tracking.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • rohilsurana
  • whoAbhishekSah

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (5)
core/resource/service_test.go (3)

23-35: Consider a struct-based test harness to tame the 9-value tuple.

The newTestService return signature now returns 9 values, and every test site repeats the _, _, …, svc := newTestService(t) dance. A small testDeps struct (or returning *testDeps + *resource.Service) would make call sites far less error-prone and future-proof against further dependencies. Not blocking.

♻️ Example refactor
-func newTestService(t *testing.T) (*mocks.Repository, *mocks.ConfigRepository, *mocks.RelationService, *mocks.AuthnService, *mocks.ProjectService, *mocks.OrgService, *mocks.PATService, *mocks.AuditRepository, *resource.Service) {
-	t.Helper()
-	repo := mocks.NewRepository(t)
-	configRepo := mocks.NewConfigRepository(t)
-	relationSvc := mocks.NewRelationService(t)
-	authnSvc := mocks.NewAuthnService(t)
-	projectSvc := mocks.NewProjectService(t)
-	orgSvc := mocks.NewOrgService(t)
-	patSvc := mocks.NewPATService(t)
-	auditRepo := mocks.NewAuditRepository(t)
-	svc := resource.NewService(repo, configRepo, relationSvc, authnSvc, projectSvc, orgSvc, patSvc, auditRepo)
-	return repo, configRepo, relationSvc, authnSvc, projectSvc, orgSvc, patSvc, auditRepo, svc
-}
+type testDeps struct {
+	repo        *mocks.Repository
+	configRepo  *mocks.ConfigRepository
+	relationSvc *mocks.RelationService
+	authnSvc    *mocks.AuthnService
+	projectSvc  *mocks.ProjectService
+	orgSvc      *mocks.OrgService
+	patSvc      *mocks.PATService
+	auditRepo   *mocks.AuditRepository
+	svc         *resource.Service
+}
+
+func newTestService(t *testing.T) *testDeps {
+	t.Helper()
+	d := &testDeps{
+		repo:        mocks.NewRepository(t),
+		configRepo:  mocks.NewConfigRepository(t),
+		relationSvc: mocks.NewRelationService(t),
+		authnSvc:    mocks.NewAuthnService(t),
+		projectSvc:  mocks.NewProjectService(t),
+		orgSvc:      mocks.NewOrgService(t),
+		patSvc:      mocks.NewPATService(t),
+		auditRepo:   mocks.NewAuditRepository(t),
+	}
+	d.svc = resource.NewService(d.repo, d.configRepo, d.relationSvc, d.authnSvc, d.projectSvc, d.orgSvc, d.patSvc, d.auditRepo)
+	return d
+}

383-420: Consider asserting the audit record payload, not just AnythingOfType.

TestCreate is the primary guard for the new audit-emission behavior. Matching only the parameter type means regressions like a missing OrgID, wrong Event, or an incorrect Target.Type wouldn't be caught. Consider mock.MatchedBy (or a captured arg) to assert Event == pkgauditrecord.ResourceCreatedEvent, Resource.ID == project.ID, Target.ID == newResource.ID, and OrgID == project.Organization.ID.


383-397: Nit: patSvc.GetByID shouldn't actually be reachable in this test.

With the authenticated principal carrying a matching PAT.ID == patID, resolvePATUser satisfies via the context fast path and never calls patService.GetByID. The .Maybe() masks this; consider removing the patSvc expectation here and adding a separate test that specifically covers the DB fallback (explicit-principal path with PAT unset in context). That way each path is genuinely exercised.

core/resource/service.go (2)

84-105: Minor: GetPrincipal is invoked twice on the common PAT path.

When principalID is empty and the caller is a PAT, GetPrincipal is called on line 90 and then again inside resolvePATUser (line 296). It's harmless but an unnecessary round-trip on the hot path. You can piggyback off the already-fetched principal:

♻️ Suggested tweak
-	principalID := res.PrincipalID
-	principalType := res.PrincipalType
-	if strings.TrimSpace(principalID) == "" {
-		principal, err := s.authnService.GetPrincipal(ctx)
-		if err != nil {
-			return Resource{}, err
-		}
-		principalID = principal.ID
-		principalType = principal.Type
-	}
-	// PAT → resolve to underlying user
-	if principalType == schema.PATPrincipal {
-		sub, err := s.resolvePATUser(ctx, principalID)
-		if err != nil {
-			return Resource{}, fmt.Errorf("resolving PAT principal: %w", err)
-		}
-		principalID = sub.ID
-		principalType = sub.Namespace
-	}
+	principalID := res.PrincipalID
+	principalType := res.PrincipalType
+	if strings.TrimSpace(principalID) == "" {
+		principal, err := s.authnService.GetPrincipal(ctx)
+		if err != nil {
+			return Resource{}, err
+		}
+		if principal.PAT != nil {
+			principalID, principalType = principal.PAT.UserID, schema.UserPrincipal
+		} else {
+			principalID, principalType = principal.ID, principal.Type
+		}
+	} else if principalType == schema.PATPrincipal {
+		// explicit PAT subject (e.g., admin federated call) — resolve via DB
+		sub, err := s.resolvePATUser(ctx, principalID)
+		if err != nil {
+			return Resource{}, fmt.Errorf("resolving PAT principal: %w", err)
+		}
+		principalID, principalType = sub.ID, sub.Namespace
+	}

57-59: Interface naming nit: consider AuditRecordRepository for consistency.

The struct field is auditRepo, but the concrete type in cmd/serve.go is auditRecordRepository (postgres.NewAuditRecordRepository(...)) and the package is auditrecord. Naming the local interface AuditRecordRepository (matching core/organization, core/membership, and userpat usages that also depend on the same repo) keeps the mental model consistent across services.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7e525389-e548-4abd-a686-8f525019f8b7

📥 Commits

Reviewing files that changed from the base of the PR and between a8a19f3 and bdc8e1b.

📒 Files selected for processing (8)
  • cmd/serve.go
  • core/project/service.go
  • core/resource/mocks/audit_repository.go
  • core/resource/service.go
  • core/resource/service_test.go
  • internal/api/v1beta1connect/authorize.go
  • internal/api/v1beta1connect/mocks/membership_service.go
  • pkg/auditrecord/consts.go

@coveralls
Copy link
Copy Markdown

Coverage Report for CI Build 24607736829

Coverage increased (+0.3%) to 42.071%

Details

  • Coverage increased (+0.3%) from the base build.
  • Patch coverage: 12 uncovered changes across 3 files (35 of 47 lines covered, 74.47%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
internal/api/v1beta1connect/authorize.go 6 0 0.0%
core/resource/service.go 37 32 86.49%
cmd/serve.go 1 0 0.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 36961
Covered Lines: 15550
Line Coverage: 42.07%
Coverage Strength: 11.84 hits per line

💛 - Coveralls

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
core/resource/service_test.go (1)

373-374: Tighten audit record assertions to lock in content contract.

All three TestCreate subtests match the audit record with mock.AnythingOfType("models.AuditRecord"), which will accept a completely empty record. This is why the missing Actor field in createAuditRecord (flagged in core/resource/service.go) slips through. Using mock.MatchedBy on at least one subtest to assert Event, Target.ID == createdRes.ID, OrgID, and — once fixed — Actor.ID/Actor.Type would prevent regressions on the audit payload.

♻️ Example tightening for the PAT subtest
-		auditRepo.EXPECT().Create(mock.Anything, mock.AnythingOfType("models.AuditRecord")).
-			Return(auditmodels.AuditRecord{}, nil)
+		auditRepo.EXPECT().Create(mock.Anything, mock.MatchedBy(func(ar auditmodels.AuditRecord) bool {
+			return ar.Event == pkgauditrecord.ResourceCreatedEvent &&
+				ar.Target != nil && ar.Target.Name == "res-pat" &&
+				ar.OrgID == testProject.Organization.ID &&
+				ar.Actor.ID == patID // once Actor is populated
+		})).Return(auditmodels.AuditRecord{}, nil)

Also applies to: 411-412, 438-439


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c4cb959a-9859-4762-af52-2bb10561fc2e

📥 Commits

Reviewing files that changed from the base of the PR and between bdc8e1b and 1903b54.

📒 Files selected for processing (3)
  • core/resource/mocks/audit_record_repository.go
  • core/resource/service.go
  • core/resource/service_test.go

Comment thread core/resource/service.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants