Skip to content

feat: wire in penguin-aaa (RBAC, tenant + audit middleware)#137

Open
PenguinzTech wants to merge 1 commit into
mainfrom
feat/wire-penguin-aaa
Open

feat: wire in penguin-aaa (RBAC, tenant + audit middleware)#137
PenguinzTech wants to merge 1 commit into
mainfrom
feat/wire-penguin-aaa

Conversation

@PenguinzTech

@PenguinzTech PenguinzTech commented May 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • apps/api/auth/rbac.py (new): RBACEnforcer singleton with observer / editor / admin roles defined in resource:action scope format. PERMISSION_SCOPE_MAP bridges legacy permission strings (manage_users, view_users, etc.) to scopes. check_permission() replaces the _check_user_permission stub that previously returned True for everyone.
  • apps/api/auth/jwt_handler.py: generate_token() now embeds tenant, roles, and scope in the JWT payload — existing tokens without these fields still work (they just won't carry scope/role info until re-issued).
  • apps/api/auth/decorators.py: stubs replaced with real enforcer.has_scope() calls; adds @require_scope and @require_role Quart-native decorators that read g.claims (no extra DB round-trip).
  • apps/api/main.py: create_asgi_app() wraps Quart with TenantMiddleware(required=False) then AuditMiddleware(StdoutSink). A before_request hook populates g.claims from the decoded JWT so decorators work without touching the DB.
  • requirements.in / requirements.txt: adds penguin-aaa==0.1.0.

ASGI middleware stack

uvicorn
  └── AuditMiddleware       ← emits JSON audit event per request to stdout
        └── TenantMiddleware (required=False)  ← extracts tenant claim when present
              └── Quart app
                    └── before_request: g.claims populated from JWT
                          └── route handler

TenantMiddleware runs in non-required mode because Elder's own JWTs now carry tenant only after fresh login — older tokens without it still pass through. Switch to required=True once all tokens are re-issued.

What's NOT changed

  • All 30+ blueprint files — zero changes to route handlers
  • @login_required behavior — still validates against the DB
  • @resource_role_required — still queries resource_roles table (unchanged)

Test plan

  • POST /api/v1/auth/login → returned token now contains tenant, roles, scope fields
  • GET /api/v1/identities with observer role token → 403 (no users:read scope... wait, observer has users:read — use manage_users endpoint) → test with non-admin identity hits @permission_required("manage_users") → 403
  • Superuser bypass: is_superuser=True → all permissions pass regardless of role
  • Audit log: each request emits a JSON line to stdout with subject, action, path, status_code
  • CI lint and build pass

🤖 Generated with Claude Code

Summary by Sourcery

Integrate role- and scope-based authorization and tenant/audit middleware into the API, wiring JWT claims into request context for RBAC-aware access control.

New Features:

  • Add RBACEnforcer-based role and scope definitions with legacy permission-to-scope mapping for portal roles.
  • Embed tenant, roles, and scope claims into issued JWTs based on the identity's portal role to support downstream RBAC and tenancy.
  • Introduce decorators to enforce required scopes or roles on routes using JWT-derived claims without extra database lookups.
  • Provide an ASGI factory that wraps the Quart app with tenant and audit middleware and exposes claims via a before_request hook.

Enhancements:

  • Replace permissive permission stubs with RBAC-backed helpers for global and organization-level permission checks.
  • Run the development server using the new ASGI app factory instead of the bare Quart app.
  • Add penguin-aaa as a dependency for authorization and middleware support.

Replaces the _check_user_permission / _check_org_permission stubs with
real enforcement via penguin-aaa's RBACEnforcer. Adds ASGI middleware
wrapping (TenantMiddleware + AuditMiddleware) around the Quart app, and
a before_request bridge that populates g.claims from Elder's JWT so
@require_scope / @require_role decorators work without an extra DB call.

Key changes:
- apps/api/auth/rbac.py (new): RBACEnforcer singleton with observer /
  editor / admin roles; PERMISSION_SCOPE_MAP bridging legacy names;
  check_permission() / check_org_permission() / role_scopes()
- apps/api/auth/jwt_handler.py: generate_token() now embeds tenant,
  roles, and scope in the payload for penguin-aaa compatibility
- apps/api/auth/decorators.py: stubs replaced with rbac.check_permission;
  adds @require_scope and @require_role Quart-native decorators
- apps/api/main.py: create_asgi_app() wraps Quart with TenantMiddleware
  (required=False) and AuditMiddleware(StdoutSink); before_request bridge
  populates g.claims; __main__ updated to use create_asgi_app()
- requirements.in / requirements.txt: add penguin-aaa==0.1.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented May 21, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduces penguin-aaa–based RBAC, tenant, and audit integration by wiring an RBACEnforcer singleton, enriching JWTs with role/scope/tenant claims, adding scope/role decorators that read JWT-derived claims from g, and wrapping the Quart app with TenantMiddleware and AuditMiddleware for ASGI deployment, while keeping existing blueprints and DB-based login/resource_role checks intact.

Sequence diagram for JWT claims and RBAC-aware request handling

sequenceDiagram
    actor User
    participant Uvicorn
    participant AuditMiddleware
    participant TenantMiddleware
    participant QuartApp
    participant BeforeRequest
    participant Decorators

    User->>Uvicorn: HTTP request with Authorization header
    Uvicorn->>AuditMiddleware: ASGI call
    AuditMiddleware->>TenantMiddleware: ASGI call
    TenantMiddleware->>QuartApp: ASGI call
    QuartApp->>BeforeRequest: populate_claims()
    BeforeRequest->>BeforeRequest: get_token_from_header()
    BeforeRequest->>BeforeRequest: verify_token(token)
    BeforeRequest->>BeforeRequest: g.claims = {sub, tenant, roles, scope}
    BeforeRequest-->>QuartApp: continue
    QuartApp->>Decorators: login_required()
    Decorators->>Decorators: get_current_user()
    QuartApp->>Decorators: require_scope("secrets:read")
    Decorators->>Decorators: read g.claims["scope"]
    Decorators-->>QuartApp: 403 if scope missing
    QuartApp-->>User: HTTP response
Loading

File-Level Changes

Change Details Files
Replace stubbed permission helpers with penguin-aaa–backed RBAC checks and add scope/role decorators that use JWT claims.
  • Wire decorators module to import RBAC helpers and enforcer
  • Change _check_user_permission to delegate to check_permission and remove permissive stub behavior
  • Add _check_org_permission delegating to check_org_permission for org-aware checks
  • Introduce @require_scope decorator that validates required scopes against g.claims["scope"] with superuser bypass and async/Sync compatibility
  • Introduce @require_role decorator that validates required roles against g.claims["roles"] with superuser bypass and async/Sync compatibility
apps/api/auth/decorators.py
Introduce centralized RBAC role/scope configuration and legacy permission-to-scope mapping using penguin-aaa.
  • Define observer/editor/admin scope catalogues using resource:action naming
  • Instantiate a global RBACEnforcer and register Role objects for each portal role
  • Add PERMISSION_SCOPE_MAP to translate legacy permission names into scopes
  • Implement check_permission to enforce permissions via role→scope mapping with superuser bypass and safe default-deny behavior
  • Implement check_org_permission as a thin wrapper around check_permission pending per-org RBAC
  • Expose role_scopes helper to compute scopes for a given portal role for JWT embedding
apps/api/auth/rbac.py
Enrich JWT tokens with tenant, role, and scope claims for penguin-aaa compatibility while preserving existing behavior for older tokens.
  • Import role_scopes from new RBAC module inside generate_token to avoid circular imports
  • Derive portal_role from identity.portal_role with observer as the default
  • Derive tenant from identity.tenant_id as a string, defaulting to empty string
  • Extend JWT payload to include tenant, roles (single portal_role), and scope (role_scopes for access tokens, empty for refresh)
  • Keep core JWT fields (sub, username, type, iat, exp) unchanged so existing validation continues to work
apps/api/auth/jwt_handler.py
Wrap the Quart app in penguin-aaa Tenant and Audit middleware for ASGI deployment and bridge JWT payloads into g.claims before each request.
  • Introduce create_asgi_app that builds the Quart app via create_app, wraps it in TenantMiddleware(required=False) then AuditMiddleware with StdoutSink emitter, and returns the ASGI callable
  • Register a _register_before_request helper that installs a before_request hook to extract the bearer token, verify it, and populate g.claims with sub, tenant, roles, and scope from the JWT
  • Call _register_before_request from create_app so tests and non-ASGI use still get g.claims populated
  • Change the uvicorn entry point to use create_asgi_app instead of create_app so middleware is active in production
apps/api/main.py
Add penguin-aaa dependency for RBAC and middleware support.
  • Add penguin-aaa==0.1.0 to requirements.in
  • Lock penguin-aaa==0.1.0 with hashes in requirements.txt
requirements.in
requirements.txt

Possibly linked issues

  • #[Chore] Evaluate penguin-aaa for authentication layer: PR begins migrating Elder auth to penguin-aaa with RBAC, tenant, and audit middleware using OIDC-style claims/scopes.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai 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.

Hey - I've found 1 issue, and left some high level feedback:

  • The new require_scope/require_role decorators use inspect.iscoroutinefunction but inspect is not imported in apps/api/auth/decorators.py, which will raise a NameError at runtime.
  • The portal_role handling is now duplicated and slightly divergent between jwt_handler.generate_token, rbac.check_permission, and rbac.role_scopes; consider centralizing the defaulting/validation of role names in one helper to avoid future drift.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `require_scope`/`require_role` decorators use `inspect.iscoroutinefunction` but `inspect` is not imported in `apps/api/auth/decorators.py`, which will raise a `NameError` at runtime.
- The `portal_role` handling is now duplicated and slightly divergent between `jwt_handler.generate_token`, `rbac.check_permission`, and `rbac.role_scopes`; consider centralizing the defaulting/validation of role names in one helper to avoid future drift.

## Individual Comments

### Comment 1
<location path="apps/api/auth/rbac.py" line_range="75-84" />
<code_context>
+PERMISSION_SCOPE_MAP: dict[str, str] = {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Unknown permission names silently returning False may make debugging harder.

In `check_permission`, unknown `permission_name` values (not in `PERMISSION_SCOPE_MAP`) cause a silent `False` result. While safe for security, this hides configuration or typo bugs and makes them hard to trace. Consider logging (e.g., debug/warning) when `scope is None`, or raising in non-prod, so misconfigured or missing permissions are surfaced early.

Suggested implementation:

```python
def check_permission(user: User, permission_name: str) -> bool:
    """Check an old-style permission name against the RBAC scopes.

    Unknown permission names are treated as configuration/typo errors and logged
    so they can be found early, while still returning False for safety.
    """

    scope = PERMISSION_SCOPE_MAP.get(permission_name)
    if scope is None:
        # Unknown permission name: surface this as a misconfiguration instead
        # of failing silently.
        logger.warning(
            "Unknown permission_name '%s' passed to check_permission; "
            "no matching scope found in PERMISSION_SCOPE_MAP",
            permission_name,
        )

        # In non-production environments, fail fast to catch configuration bugs.
        if getattr(settings, "DEBUG", False):
            raise ValueError(
                f"Unknown permission_name '{permission_name}' passed to check_permission"
            )

        return False

    return enforcer.check(user, scope)

```

I assumed the existence of a module-level `logger` and a `settings` object (e.g., Django-style). To wire this up fully you should:

1. At the top of `apps/api/auth/rbac.py`, ensure you have:
   - `import logging`
   - `from django.conf import settings` (or your project’s equivalent settings import)
   - `logger = logging.getLogger(__name__)`
2. If your project uses a different logging/settings pattern, adjust `logger` and `settings.DEBUG` accordingly (e.g., `from apps.core import settings` or `from config import settings`).
3. Optionally, if you prefer a different non-prod toggle than `settings.DEBUG`, replace that condition with your environment check (e.g., `not settings.IS_PROD`).
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread apps/api/auth/rbac.py
Comment on lines +75 to +84
PERMISSION_SCOPE_MAP: dict[str, str] = {
"manage_users": "users:write",
"view_users": "users:read",
"create_entity": "entities:write",
"edit_entity": "entities:write",
"delete_entity": "entities:delete",
"view_entity": "entities:read",
"create_organization": "organizations:write",
"edit_organization": "organizations:write",
"delete_organization": "organizations:delete",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (bug_risk): Unknown permission names silently returning False may make debugging harder.

In check_permission, unknown permission_name values (not in PERMISSION_SCOPE_MAP) cause a silent False result. While safe for security, this hides configuration or typo bugs and makes them hard to trace. Consider logging (e.g., debug/warning) when scope is None, or raising in non-prod, so misconfigured or missing permissions are surfaced early.

Suggested implementation:

def check_permission(user: User, permission_name: str) -> bool:
    """Check an old-style permission name against the RBAC scopes.

    Unknown permission names are treated as configuration/typo errors and logged
    so they can be found early, while still returning False for safety.
    """

    scope = PERMISSION_SCOPE_MAP.get(permission_name)
    if scope is None:
        # Unknown permission name: surface this as a misconfiguration instead
        # of failing silently.
        logger.warning(
            "Unknown permission_name '%s' passed to check_permission; "
            "no matching scope found in PERMISSION_SCOPE_MAP",
            permission_name,
        )

        # In non-production environments, fail fast to catch configuration bugs.
        if getattr(settings, "DEBUG", False):
            raise ValueError(
                f"Unknown permission_name '{permission_name}' passed to check_permission"
            )

        return False

    return enforcer.check(user, scope)

I assumed the existence of a module-level logger and a settings object (e.g., Django-style). To wire this up fully you should:

  1. At the top of apps/api/auth/rbac.py, ensure you have:
    • import logging
    • from django.conf import settings (or your project’s equivalent settings import)
    • logger = logging.getLogger(__name__)
  2. If your project uses a different logging/settings pattern, adjust logger and settings.DEBUG accordingly (e.g., from apps.core import settings or from config import settings).
  3. Optionally, if you prefer a different non-prod toggle than settings.DEBUG, replace that condition with your environment check (e.g., not settings.IS_PROD).

@github-actions

Copy link
Copy Markdown

CI Summary

Job Results

  • Python Lint: failure
  • Python Unit: success
  • Python Integration: success
  • Web Build: skipped
  • Docker Smoke: failure
  • Security: success
  • License: success

Branch: 137/merge | Commit: 48a3d5f | Run: #710

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