Permission grants: lead-in-training access (global or per-project) - #171
Permission grants: lead-in-training access (global or per-project)#171hhff wants to merge 9 commits into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements PermissionGrant model with: - Global and scoped (ProjectTracker) permissions - Polymorphic subject association - Auto-default subject_type to ProjectTracker - Uniqueness validation across scope - Scopes for global grants and permission filtering - Full test coverage for all validations and behaviors Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-scoped read-only) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e/demote Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
InvoicePass#index rendered value/outstanding/surplus and invoicing/payout status columns unconditionally, and the show page's missing-hours report listed every contributor company-wide. Project-scoped "lead" grantees get unconditional :read on InvoicePass so they can navigate to their own invoice trackers, which let them read the whole company's monthly revenue totals. Gate the aggregate columns and the hours_report to admins/leads only; scoped users still see start_of_month + navigation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ActiveAdmin's apply_authorization_scope calls scope_collection with whatever scoped_collection returns. For top-level resources without a scoped_collection override, that's the bare model Class (via InheritedResources' end_of_association_chain), not a Relation. The old `collection.klass.name` blew up with NoMethodError on Class#klass, 500ing every such index (e.g. /admin/ledgers) for project-scoped grantees. Pages that override scoped_collection (returning a Relation) were unaffected, which is why the unit tests — which always passed a Relation — missed it. Resolve the model via `collection.respond_to?(:klass) ? collection.klass : collection` before switching on its name; the where/none calls in the existing branches work unchanged on a bare Class. Authorization semantics are untouched — the scoped-only trainee still gets denied read access to Ledger via authorized?, now via a clean redirect instead of a crash. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial authorization panel — resultsRan a multi-agent adversarial review against this branch: five attacker personas (project-scoped grantee hunting writes, grant lifecycle/edge-state, global-grantee privilege boundary, impersonation layer, pure data-egress), then two independent verifiers per finding — one skeptic required to trace the path end-to-end and default to "refuted", one reproducer required to write a throwaway integration test. Four findings survived. Fixed here:
|
Summary
permission_grantstable +PermissionGrantmodel:{admin_user, permission, optional polymorphic subject, granted_by, notes}— extensible to new permissions/scopes without schema changes"lead"grant ⇒AdminUser#can_act_as_lead?⇒ identical ActiveAdmin access to someone who has actually led (for team/account leads in training)AdminAuthorization#scope_collection; jsonb blueprint → ProjectTrackerForecastProject mapping)granted_bystamped server-side, nested grant params stripped for non-adminspromote_admin_user/demote_admin_usermember actions now verifyis_admin?server-side (previously only the link was hidden — any lead could POST to self-promote; the TDD run reproduced the escalation before fixing it)Defaulted decisions (see spec)
expires_at— training grants are revoked manuallyTesting
AdminUserTest(salary-windowDate.todayvs UTC rollover; code untouched by this branch), 2 pre-existing skipsDeferred follow-ups (from reviews)
can_act_as_lead?/ scoped ids at the adapter layer (query amplification on menu rendering)on_delete: :nullifyforpermission_grants.granted_by_idaction_itembuttons on pages scoped users can see (dead-button UX)::bigintcast in the blueprint jsonb filter for legacy dataSpec:
docs/superpowers/specs/2026-08-04-permission-grants-design.mdPlan:
docs/superpowers/plans/2026-08-04-permission-grants.md🤖 Generated with Claude Code