Skip to content

feat(permissions): add GET /permissions/can check endpoint - #533

Merged
zaira-bibi merged 2 commits into
mainfrom
zaira/feat/analytics-dashboard-s3
Jul 28, 2026
Merged

feat(permissions): add GET /permissions/can check endpoint#533
zaira-bibi merged 2 commits into
mainfrom
zaira/feat/analytics-dashboard-s3

Conversation

@zaira-bibi

@zaira-bibi zaira-bibi commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Part of: #487 — Analytics are not visible anywhere in the product UI

Backend prerequisite. The frontend that consumes this (the analytics dashboard, stacked PR #539) closes #487. Part of the analytics epic #434.

What

Add a server-side single-permission check endpoint so a client (e.g. the web UI) can ask whether the authenticated user holds a specific permission and gate UI precisely — there is no admin bypass, so is_admin alone is the wrong signal.

Changes

  • feat(permissions): GET /api/v1/permissions/can?permission=<name>&scope=<kind>&scope_object_id=<id?>{"allowed": bool}, auth-only, backed directly by the engine's can(); unknown permission/scope names map to 422 via the exception registry
  • refactor(permissions): drop the initial approach (a permissions list on /user/me + a get_user_permissions helper) in favour of the check endpoint, per review feedback — keeps the authorization decision on the server (one source of truth, incl. the scope cascade), extends to object-scoped checks, and discloses only the answer asked for

How to Test

  1. Backend suites — all green:
    uv run pytest tests/api/v1/test_permission_check.py tests/api/v1/test_user.py tests/permissions/ -v
  2. Gates: make mypy and make lint.backend → clean.
  3. Manual (app running): grant a user a role containing analytics.read at the global scope
    (make cli -- roles assign-role <user> <role>), authenticate, then
    GET /api/v1/permissions/can?permission=analytics.read{"allowed": true};
    a user without the grant → {"allowed": false}.
  4. An unknown permission (?permission=does.not.exist) or scope (&scope=nope) → 422; unauthenticated → 403.

Notes

  • Supersedes this PR's earlier /user/me permissions-list design (reworked per TL feedback). No migration; no new dependencies.
  • The check endpoint is a UI-gating convenience, not a security boundary — every endpoint still enforces its own permission and returns 403.
  • The frontend consumer (analytics dashboard) is the stacked PR feat(frontend): add analytics dashboard at /dashboard/analytics #539; it will call this endpoint to gate the nav entry.

This PR description was written with the assistance of an LLM (Claude).

@zaira-bibi zaira-bibi self-assigned this Jul 23, 2026
@zaira-bibi zaira-bibi added the enhancement New feature or request label Jul 23, 2026
@zaira-bibi

Copy link
Copy Markdown
Contributor Author

@claude-review

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @zaira-bibi's task in 1m 25s —— View job


Claude Code Review

  • Gather context (read changed files & diff)
  • Review permissions core changes
  • Review API + schema changes
  • Review frontend + docs changes
  • Validate test coverage
  • Post review feedback

Reviewed the full diff against origin/main. This is a clean, well-scoped, TDD-first change. get_user_permissions mirrors can() faithfully (same _active_assignment_at_scope selection, so the objectless-ancestor cascade is honoured identically), the permissions field is additive/defaulted so non-/user/me responses stay non-privileged, and the docstrings/guide were updated alongside the code. Test coverage is thorough: granted names, empty, dedup across two roles, and the cascade direction — plus the two API-level tests. Nothing blocking.

Correctness ✅

  • The .distinct() on RolePermission.permission correctly dedups a permission granted via multiple roles — covered by test_deduplicates_permission_granted_via_two_roles.
  • Reusing _active_assignment_at_scope (rather than re-deriving the scope chain) keeps get_user_permissions and can() in lockstep — if the cascade logic changes, both move together. Good DRY.
  • Pydantic mutable default permissions: list[str] = [] is safe (Pydantic deep-copies field defaults per instance), and model_copy(update=...) sets the computed list without re-validating the ORM object. Correct.

Suggestions (non-blocking)

Low — non-deterministic result order. select(...).distinct() has no ORDER BY, so the returned list order is backend-dependent. It's harmless for the frontend nav-gating use case (membership test), and the unit tests guard against it with sorted(...), but a stable order makes responses reproducible and easier to snapshot/assert against later. Optional:

.distinct()
.order_by(col(RolePermission.permission))

Low — two round-trips on /user/me. The handler now issues two queries (has_role for is_admin, then get_user_permissions). Fine at this scale and clearer than a fused query — noting it only for awareness; no change requested.

Test coverage note (optional)

The cascade test covers the parent→child direction (a GLOBAL grant visible at WHITELIST). If you want to lock in the non-cascade direction too, a case asserting that a grant made at a descendant scope does not surface when querying GLOBAL would round out the guarantee — but can()'s existing suite likely already pins this, so it's genuinely optional.

Overall: LGTM. 👍 Correct, tested, documented, and non-breaking.
zaira/feat/analytics-dashboard-s3

@zaira-bibi
zaira-bibi requested review from abdulrafey1, hamza-56 and regisb and removed request for abdulrafey1 and hamza-56 July 24, 2026 07:28
zaira-bibi added a commit that referenced this pull request Jul 24, 2026
Milestone 1's last leg: login activity is visible in-app. A client component
fetches GET /api/v1/analytics/login-activity once, zero-fills the sparse
(zero-day-omitting) response into a continuous 30-day series, and renders it as
a hand-rolled SVG bar chart plus two stat tiles (total logins, busiest day) —
all derived from the same windowed series so the headline numbers always match
the bars. The nav entry is gated on the analytics.read permission (surfaced on
/user/me by #533), with a 403 in-page state for defense in depth.

No new dependencies: BarChart and StatCard are added to the in-repo
shadcn-style components/ui set (themed SVG, dark-mode-native).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zaira-bibi zaira-bibi changed the title feat(permissions): expose effective permissions on /user/me feat(permissions): add GET /permissions/can check endpoint Jul 24, 2026
@zaira-bibi
zaira-bibi force-pushed the zaira/feat/analytics-dashboard-s3 branch from 8ee6696 to 6aa4eb3 Compare July 24, 2026 14:47
Comment thread sparkth/api/v1/permissions/routes/__init__.py Outdated
@zaira-bibi
zaira-bibi force-pushed the zaira/feat/analytics-dashboard-s3 branch 2 times, most recently from d71551f to 065a006 Compare July 28, 2026 07:01
Zaira Bibi and others added 2 commits July 28, 2026 12:06
The frontend needs to gate navigation on specific permissions (e.g. an
analytics dashboard on analytics.read), but the permission engine has no
admin bypass and the frontend only knew is_admin — the wrong signal.
Expose the caller's effective GLOBAL-scope permissions so the UI can
gate precisely.

Add get_user_permissions(), the all-distinct-names sibling of can(): it
reuses the same active-assignment selection (honouring the
objectless-ancestor cascade), so it inherits identical user scoping and
soft-delete semantics, and is re-exported from the
sparkth.lib.permissions façade. /user/me computes it at GLOBAL scope
alongside is_admin and attaches it without mutating the ORM object.

The User schema's `permissions` defaults to [] so responses that return
the ORM user directly (e.g. register) stay non-privileged — only
/user/me computes the real value.

Backend slice of the analytics-dashboard work (issue #487); the
dashboard UI that consumes this field lands in a separate, stacked PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review feedback: rather than returning the caller's full permission list on
/user/me for the client to check with .includes(), expose a single-permission
check — GET /api/v1/permissions/can?permission=<name>&scope=<kind> ->
{"allowed": bool} — backed directly by the engine's can(). This keeps the
authorization decision on the server (one source of truth, incl. the scope
cascade), extends naturally to object-scoped checks, and discloses only the
answer asked for. It is a UI-gating convenience, not a security boundary:
endpoints still enforce their own permissions and return 403.

Removes get_user_permissions and the permissions field on the User schema/
/user/me; unknown permission or scope names map to 422 via the exception
registry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zaira-bibi
zaira-bibi force-pushed the zaira/feat/analytics-dashboard-s3 branch from 065a006 to 71ac63d Compare July 28, 2026 08:00

@regisb regisb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

yup this is good!

@zaira-bibi
zaira-bibi merged commit af913c0 into main Jul 28, 2026
6 checks passed
@zaira-bibi
zaira-bibi deleted the zaira/feat/analytics-dashboard-s3 branch July 28, 2026 17:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Analytics are not visible anywhere in the product UI

3 participants