Skip to content

feat(authz): central authorization layer — PDP interface, manifest, builtin driver (BCH-1313)#426

Closed
gusfcarvalho wants to merge 2 commits into
mainfrom
gc-feat-authz-baseline
Closed

feat(authz): central authorization layer — PDP interface, manifest, builtin driver (BCH-1313)#426
gusfcarvalho wants to merge 2 commits into
mainfrom
gc-feat-authz-baseline

Conversation

@gusfcarvalho

@gusfcarvalho gusfcarvalho commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

BCH-1313 — AuthZ Phase 1

Introduces CCF's central authorization layer behind one PEP middleware + a pluggable PDP driver interface, with zero behavior change. Existing access rules are unchanged; they are now expressed through the new layer.

Jira: https://container-solutions.atlassian.net/browse/BCH-1313
Design: CCF Pluggable Authorization — Design Plan

What's in this PR

  • internal/authz package: PDP interface (Evaluate + batch Evaluations), Subject/Resource/Decision/EvalRequest types, and a driver registry (authz.Register/Open) mirroring database/sql.
  • Manifest loader + embedded internal/authz/manifest.yaml — engine-neutral vocabulary covering the migrated routes (evidence, admin). Attributes are intentionally minimal/provisional (the authoritative per-resource attribute surface is gated on BCH-1319).
  • builtin driver reproducing today's rules exactly: authenticated ⇒ allowed; admin routes require the SSO admin groups previously enforced by RequireAdminGroups. As the in-process default it self-fetches the admin facts it needs (user row, SSO link, provider config).
  • mw.Authorizer.Authorize(resource, action) PEP middleware: builds the subject from user/agent claims, calls the PDP, enforces the result. Deny → 403 (reason logged, never echoed); evaluator error honours authz.fail_mode (default closed → 500 for internal failures, preserving prior behavior).
  • Migrations: RequireAdminGroups is now a thin wrapper over the PEP (Authorize("admin","manage")); the evidence-create route (evidence_auth.go / OptionalUserOrAgentJWTMiddleware) gains Authorize("evidence","create") while keeping its authentication semantics (401 for missing creds, 403 for bad agent keys) intact.
  • Config (Viper): authz_driver (CCF_AUTHZ_DRIVER, default builtin) and authz_fail_mode (CCF_AUTHZ_FAIL_MODE, default closed).

Notable decisions (see git notes show / lisa-design)

  • Authentication (401) and authorization (403) are kept as distinct concerns; evidence_auth.go stays the authn layer.
  • The builtin admin check mirrors RequireAdminGroups branch-for-branch; only the user-load path distinguishes not-found (deny/403) from a DB error (500).
  • The per-resource evaluation attribute surface is deliberately not grown here — that is the BCH-1319 design task.

Testing

  • New unit tests: builtin driver (every admin branch + authenticated/public), manifest loader/validation, registry, and the PEP middleware (allow/deny/fail-open/fail-closed/OPTIONS/subject-building).
  • Regression: go build ./..., go vet ./..., full unit suite, and the evidence, templates (admin) and workflow-import (admin) integration suites all pass — confirming zero behavior change.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Implemented a centralized authorization system enabling fine-grained access control across resources and actions.
    • Added configurable enforcement of admin group membership via identity provider integration.
    • Introduced ability to restrict or permit public access to specific endpoints through configuration.
  • Refactor

    • Consolidated authorization middleware to use a unified policy-based enforcement model.

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces a pluggable authorization system. A new internal/authz package defines a PDP interface, driver registry, YAML manifest vocabulary, and a built-in driver that enforces general resource access and SSO admin group membership. An Authorizer Echo middleware (PEP) is added to internal/api/middleware. Config gains an AuthZConfig field. The evidence-create route and RequireAdminGroups middleware are wired through the central authorizer.

Changes

Pluggable Authorization System

Layer / File(s) Summary
authz core contracts: PDP interface, manifest, registry, config
internal/authz/pdp.go, internal/authz/manifest.yaml, internal/authz/manifest.go, internal/authz/manifest_test.go, internal/authz/registry.go, internal/authz/registry_test.go, internal/config/authz.go, internal/config/config.go
Defines Subject, Resource, Decision, EvalRequest, and the PDP interface; introduces the embedded authorization vocabulary YAML manifest with evidence/admin resources; adds the driver registry with Register/Open/Drivers and FailMode; adds AuthZConfig struct loaded via Viper and wired into Config. Tests cover manifest parsing/validation/loading and registry behavior.
Built-in PDP driver
internal/authz/builtin.go, internal/authz/builtin_test.go
Registers the builtin driver and implements builtinPDP.Evaluate: non-admin resources allow authenticated subjects and anonymous subjects only when allow_public=true; admin resource delegates to evaluateAdmin which replicates the prior SSO group enforcement logic (user load, SSO link, provider config, group normalization). Tests cover all SSO/non-SSO branches, anonymous access, batch evaluations, and missing-DB error.
Authorizer Echo middleware (PEP)
internal/api/middleware/authorize.go, internal/api/middleware/authorize_test.go
Adds Authorizer type with NewAuthorizer/NewAuthorizerFromConfig, WithPublicAccess/WithResourceIDParam option helpers, and the core Authorize middleware (OPTIONS bypass, nil-PDP deny, fail-open/closed PDP error handling, generic 403 on deny). subjectFromContext derives user/agent/anonymous subjects from Echo context claims. Tests cover allow/deny, error modes, OPTIONS bypass, nil PDP, all subject types, and context/resource forwarding.
Wire routes through central PEP
internal/api/handler/api.go, internal/api/middleware/admin.go
RegisterHandlers instantiates a central Authorizer via NewAuthorizerFromConfig and adds it to the evidence-create route with a WithPublicAccess flag. RequireAdminGroups replaces its inline SSO enforcement with a delegation to Authorize(ResourceAdmin, "manage").

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant EchoRouter
  participant Authorizer
  participant builtinPDP
  participant DB

  Client->>EchoRouter: POST /evidence
  EchoRouter->>Authorizer: Authorize("evidence", "create", WithPublicAccess(...))
  Authorizer->>Authorizer: subjectFromContext → Subject{user/agent/anon}
  Authorizer->>builtinPDP: Evaluate(ctx, Subject, "create", Resource{evidence}, {allow_public: bool})
  alt Subject is authenticated
    builtinPDP-->>Authorizer: Decision{Allow: true}
  else Subject is anonymous and allow_public=true
    builtinPDP-->>Authorizer: Decision{Allow: true}
  else Subject is anonymous and allow_public=false
    builtinPDP-->>Authorizer: Decision{Allow: false}
    Authorizer-->>Client: 403 forbidden
  end
  Authorizer->>EchoRouter: next(c)
  EchoRouter-->>Client: 200 OK

  Client->>EchoRouter: GET /admin (protected)
  EchoRouter->>Authorizer: Authorize("admin", "manage") [via RequireAdminGroups]
  Authorizer->>builtinPDP: Evaluate(ctx, Subject, "manage", Resource{admin}, {})
  builtinPDP->>DB: load user by email
  builtinPDP->>DB: load SSOUserLink
  builtinPDP->>DB: resolve provider config + required groups
  alt required groups satisfied
    builtinPDP-->>Authorizer: Decision{Allow: true}
    Authorizer->>EchoRouter: next(c)
  else groups missing
    builtinPDP-->>Authorizer: Decision{Allow: false}
    Authorizer-->>Client: 403 forbidden
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A rabbit hops through policy gates,
Each request checked before it penetrates.
The PDP whispers "allow" or "deny,"
While the manifest maps every who and why.
No more inline guards scattered about—
One central Authorizer sorts it all out! 🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.19% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: introducing a central authorization layer with a PDP interface, manifest, and builtin driver.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/api/middleware/admin.go`:
- Around line 19-20: The RequireAdminGroups function creates a new Authorizer
instance on each invocation through NewAuthorizerFromConfig, causing redundant
manifest parsing and driver instantiation across 6+ call sites in api.go.
Refactor RequireAdminGroups to accept an *Authorizer parameter instead of
creating a new one, then update all call sites in api.go to pass a shared
authorizer instance. This eliminates the redundant work while maintaining the
helper function's convenience during the migration phase.

In `@internal/authz/builtin_test.go`:
- Around line 93-180: Add a new test case within the TestBuiltin_Admin function
that verifies the behavior when a database operational error occurs during SSO
link lookup. Create a test scenario using t.Run that mocks or forces the link
lookup query to fail with an operational error (not ErrRecordNotFound), then
call pdp.Evaluate with an SSO-configured user and assert that it returns an
error rather than silently denying access with Allow set to false. This ensures
the authorization decision fails safely when encountering database errors
instead of treating all failures the same way.

In `@internal/authz/builtin.go`:
- Around line 119-125: The error handling in the database query for fetching the
SSO link currently treats all query errors identically by returning a deny
decision, which incorrectly maps operational database failures to 403 responses
instead of surfacing them as 500 internal errors. You need to differentiate
between a missing record error (which legitimately means no SSO link exists for
the user) and other database errors (which are operational failures). Check the
error returned from the First call to determine if it is specifically a
record-not-found error versus other error types, then handle each case
appropriately: for record-not-found return the current deny decision, and for
other errors return an error that will trigger a fail-closed response instead of
allowing the deny decision.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d1e8b512-06f1-4c71-bc91-ddc0f6269fad

📥 Commits

Reviewing files that changed from the base of the PR and between 2c85af6 and aafce62.

📒 Files selected for processing (14)
  • internal/api/handler/api.go
  • internal/api/middleware/admin.go
  • internal/api/middleware/authorize.go
  • internal/api/middleware/authorize_test.go
  • internal/authz/builtin.go
  • internal/authz/builtin_test.go
  • internal/authz/manifest.go
  • internal/authz/manifest.yaml
  • internal/authz/manifest_test.go
  • internal/authz/pdp.go
  • internal/authz/registry.go
  • internal/authz/registry_test.go
  • internal/config/authz.go
  • internal/config/config.go

Comment thread internal/api/middleware/admin.go
Comment thread internal/authz/builtin_test.go
Comment thread internal/authz/builtin.go
Comment on lines +119 to +125
if err := p.db.WithContext(ctx).
Where("user_id = ? AND deleted_at IS NULL", user.ID.String()).
Order("last_sync DESC").
First(&link).Error; err != nil {
p.logger.Warnw("Missing SSO link for admin enforcement", "userID", user.ID.String(), "error", err)
return Decision{Allow: false, Reason: "missing SSO link for user"}, nil
}

@coderabbitai coderabbitai Bot Jun 19, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Differentiate missing SSO-link from DB failures in admin evaluation.

Line 122 currently maps all query errors to a deny decision. That collapses operational DB failures into 403s instead of surfacing them as internal errors (500 in fail-closed), which breaks the intended error contract.

Proposed fix
 	if err := p.db.WithContext(ctx).
 		Where("user_id = ? AND deleted_at IS NULL", user.ID.String()).
 		Order("last_sync DESC").
 		First(&link).Error; err != nil {
-		p.logger.Warnw("Missing SSO link for admin enforcement", "userID", user.ID.String(), "error", err)
-		return Decision{Allow: false, Reason: "missing SSO link for user"}, nil
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.logger.Warnw("Missing SSO link for admin enforcement", "userID", user.ID.String(), "error", err)
+			return Decision{Allow: false, Reason: "missing SSO link for user"}, nil
+		}
+		p.logger.Errorw("Failed to load SSO link for admin enforcement", "userID", user.ID.String(), "error", err)
+		return Decision{}, fmt.Errorf("load sso link for admin enforcement: %w", err)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if err := p.db.WithContext(ctx).
Where("user_id = ? AND deleted_at IS NULL", user.ID.String()).
Order("last_sync DESC").
First(&link).Error; err != nil {
p.logger.Warnw("Missing SSO link for admin enforcement", "userID", user.ID.String(), "error", err)
return Decision{Allow: false, Reason: "missing SSO link for user"}, nil
}
if err := p.db.WithContext(ctx).
Where("user_id = ? AND deleted_at IS NULL", user.ID.String()).
Order("last_sync DESC").
First(&link).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
p.logger.Warnw("Missing SSO link for admin enforcement", "userID", user.ID.String(), "error", err)
return Decision{Allow: false, Reason: "missing SSO link for user"}, nil
}
p.logger.Errorw("Failed to load SSO link for admin enforcement", "userID", user.ID.String(), "error", err)
return Decision{}, fmt.Errorf("load sso link for admin enforcement: %w", err)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/authz/builtin.go` around lines 119 - 125, The error handling in the
database query for fetching the SSO link currently treats all query errors
identically by returning a deny decision, which incorrectly maps operational
database failures to 403 responses instead of surfacing them as 500 internal
errors. You need to differentiate between a missing record error (which
legitimately means no SSO link exists for the user) and other database errors
(which are operational failures). Check the error returned from the First call
to determine if it is specifically a record-not-found error versus other error
types, then handle each case appropriately: for record-not-found return the
current deny decision, and for other errors return an error that will trigger a
fail-closed response instead of allowing the deny decision.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

reviewer conflict detected — pausing until resolved.

This contradicts a deliberate, documented design decision for BCH-1313 (git notes / lisa-design): the acceptance bar is ZERO behavior change, replicating middleware.RequireAdminGroups exactly. The original RequireAdminGroups returned 403 on ANY error from the SSO-link lookup (not only RecordNotFound); builtin.evaluateAdmin preserves that on purpose, so returning 500 on operational DB errors here would deviate from the original behavior. The point has merit: the user-load path DOES distinguish RecordNotFound(403) from DB-error(500), so the SSO-link path is internally inconsistent with it, and the access-control outcome is identical either way (request rejected, fail-closed) -- this is an error-contract refinement, not an access-rule change. Pausing for a human decision: relax strict zero-behavior-change so the SSO-link DB-error path returns 500 (consistent with user-load), or keep exact parity with the original 403-on-any-error for Phase 1? If we adopt 500, I will also add the regression test requested in the builtin_test.go thread.

Please resolve the conflicting feedback and re-request review.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@gusfcarvalho, thank you for the detailed conflict write-up — this is exactly the kind of context that's useful to preserve.

My position, given the full picture:

You are correct that the original RequireAdminGroups returned 403 on any SSO-link error, and the current SSO-link path faithfully preserves that. Under a strict zero-behavior-change bar, there's nothing wrong here.

However, the user-load path in the same function already departs from strict zero-behavior-change:

Condition Original RequireAdminGroups New evaluateAdmin
User not found 403 403 ✅
DB error on user load (likely 500 via unhandled error) 500 ✅
SSO link not found 403 403 ✅
DB error on SSO link load 403 403 ← the inconsistency

The user-load path already sets the precedent that "operational DB errors → propagated error → 500" is acceptable in evaluateAdmin. The SSO-link path deviates from that internal contract established within this PR, not from the original middleware.

Recommendation: Since the user-load path already relaxes the zero-behavior-change bar (returning 500 on DB errors), aligning the SSO-link path with it is a code-quality improvement that does not change access-control semantics (both paths reject the request). I'd recommend adopting the fix and adding the regression test — especially since the pattern is already established in the same function.

That said, if the maintainer decides to defer this to Phase 2 to keep the Phase 1 diff minimal, I can open a follow-up issue to track it. Would you like me to do that?


🧠 Learnings used
Learnt from: gusfcarvalho
Repo: compliance-framework/api PR: 404
File: internal/service/relational/workflows/workflow_step_definition_service.go:53-53
Timestamp: 2026-05-26T14:35:41.915Z
Learning: When reviewing Go code that uses GORM (gorm.io/gorm v1.x), be aware that `gorm.Config` embeds the `Dialector` interface and `gorm.DB` embeds `*Config`, so Go method promotion makes `Dialector.Name()` available as an idiomatic `db.Name()` call. Therefore, do not treat `db.Name()` as a compilation error on `*gorm.DB`, and linters/review guidance may prefer `db.Name()` over `db.Dialector.Name()` when both are equivalent.

@gusfcarvalho gusfcarvalho deleted the gc-feat-authz-baseline branch June 19, 2026 12:56
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.

1 participant