Skip to content

Permission grants: lead-in-training access (global or per-project) - #171

Open
hhff wants to merge 9 commits into
mainfrom
feat/permission-grants
Open

Permission grants: lead-in-training access (global or per-project)#171
hhff wants to merge 9 commits into
mainfrom
feat/permission-grants

Conversation

@hhff

@hhff hhff commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

  • New permission_grants table + PermissionGrant model: {admin_user, permission, optional polymorphic subject, granted_by, notes} — extensible to new permissions/scopes without schema changes
  • Global "lead" grant ⇒ AdminUser#can_act_as_lead? ⇒ identical ActiveAdmin access to someone who has actually led (for team/account leads in training)
  • Project-scoped grant ⇒ read-only access to that project's ProjectTracker + the InvoiceTrackers billing it (index pages filtered via new AdminAuthorization#scope_collection; jsonb blueprint → ProjectTrackerForecastProject mapping)
  • Admins manage grants from the Everybody (AdminUser) edit form; grants panel on the show page; granted_by stamped server-side, nested grant params stripped for non-admins
  • Hardening: promote_admin_user / demote_admin_user member actions now verify is_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)
  • Final-review fix: InvoicePass index company-wide aggregates (value/outstanding/surplus/status counts) and the show page's missing-hours report are now hidden from project-scoped grantees

Defaulted decisions (see spec)

  • Scoped grants are read-only; write-within-scope would be a new permission string later
  • Global grants pass the same master gate as real leads (full lead-equivalent visibility)
  • Scoped grantees can read InvoicePass pages for navigation only (aggregates gated)
  • No expires_at — training grants are revoked manually

Testing

  • Full suite: 1032 runs, 2841 assertions — 1 failure which is a pre-existing evening-PDT timezone flake in AdminUserTest (salary-window Date.today vs UTC rollover; code untouched by this branch), 2 pre-existing skips
  • New coverage: PermissionGrant validations, AdminUser predicates, adapter unit tests (global/scoped/read-only/scope_collection incl. jsonb filter against real rows), HTTP integration tests for grant injection, self-promotion, and aggregate gating

Deferred follow-ups (from reviews)

  • Memoize can_act_as_lead? / scoped ids at the adapter layer (query amplification on menu rendering)
  • on_delete: :nullify for permission_grants.granted_by_id
  • Guard lead-only action_item buttons on pages scoped users can see (dead-button UX)
  • Numeric guard before ::bigint cast in the blueprint jsonb filter for legacy data
  • One HTTP-level test pinning member-action denial for scoped users

Spec: docs/superpowers/specs/2026-08-04-permission-grants-design.md
Plan: docs/superpowers/plans/2026-08-04-permission-grants.md

🤖 Generated with Claude Code

hhff and others added 9 commits August 4, 2026 17:39
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>
@hhff

hhff commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Adversarial authorization panel — results

Ran 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: scope_collection crashed on bare model classes (regression from this branch)

AdminAuthorization#scope_collection did case collection.klass.name. ActiveAdmin passes whatever scoped_collection returns — for top-level resources that's InheritedResources' end_of_association_chain, i.e. the bare model Class, which doesn't respond to #klass. Any project-scoped grantee hitting an index without a scoped_collection override got NoMethodError: undefined method 'klass' for Ledger:Class (500). Confirmed on 12 paths: /admin/ledgers, /admin/studios, /admin/enterprises, /admin/admin_users, /admin/contacts, /admin/workspaces, /admin/project_capsules, /admin/okrs, /admin/periodic_reports, /admin/forecast_people, /admin/surveys, /admin/project_satisfaction_surveys.

The existing tests missed it because they call scope_collection(ProjectTracker.all) — a Relation. Fixed in f1d2729 by resolving the model class for either input, with regression tests at both the unit level (bare Class in) and the HTTP level (scoped grantee on /admin/ledgers now correctly 302s with "not authorized" instead of raising).

Not fixed here — pre-existing, but worth a decision (all reproduced end-to-end)

These predate this branch. Flagging them because this PR introduces a persona who is meant to be less trusted than a full lead, which changes who can reach them.

  1. POST /admin/invoice_trackers/:id/contributor_payouts/make_payouts is unauthorizedapp/admin/contributor_payouts.rb:111-115 does InvoiceTracker.find(params[...]) directly. ActiveAdmin 2.9 only invokes the authorization adapter from DataAccess#resource/#collection; a collection_action whose body never touches resource is never authorized at all. Both verifiers reproduced a scoped grantee — who cannot even read the target tracker — soft-deleting a hand-configured $4,200 payout and resetting contributor acceptance on an unrelated project. The is_admin? guard on line 10 is on the action_item (UI only).
  2. Three sync_qbo_bill member actions bypass authorization and write to QuickBookscontributor_payouts.rb:35-42, contributor_adjustments.rb:16-23, trueups.rb:21-27, same Model.find(params[:id]) mechanism. Reached with a nonexistent parent id, proving neither resource nor parent is loaded. pay_stubs.rb:23 and reimbursements.rb:22 use resource and are correctly denied — that's the fix pattern.
  3. Google OAuth matches on the email local part onlyadmin_user.rb:590-597 does auth.info.email.split("@")[0] then looks up "#{uid}@sanctuary.computer", never comparing the IdP-supplied domain; the || new fallback also auto-provisions an AdminUser for any unmatched identity. How exploitable this is depends on the Google consent-screen configuration, which isn't in this repo. (One verifier on this finding was blocked by model safeguards, so it rests on the skeptic's trace plus the auto-provision behavior, which was directly observed.)

Also worth noting: the comment at admin_authorization.rb:83-86 states that custom member-action names reach the adapter. They do — when the body touches resource. The endpoints above don't, so the comment currently documents a guarantee the code doesn't have.

What the panel probed and could not break

Default batch destroy (gated twice — display and per-record in the POST handler); CSV/JSON/XML index exports (they route through find_collection → the same scope_collection); ActiveAdmin comments (disabled); nested account_lead_periods / project_lead_periods / commissions pages; grant self-service through any route other than the AdminUser form (no PermissionGrant admin resource, no batch action, no API); nested-attribute grant hijack via a foreign id (Rails raises RecordNotFound outside the association); roles mass-assignment (absent from every permit_params); the impersonation layer (the adapter sees the impersonated user, and the promote/demote guards hold under impersonation); and grant-lifecycle edges — destroyed subjects, dangling subject ids, global+scoped combined, empty scopes, malformed jsonb blueprints.

Full suite after the fix: 1037 runs, 2857 assertions, 1 failure — the pre-existing evening-PDT AdminUserTest salary-window timezone flake (asserts Date.today against a UTC-rolled date; that code is untouched here).

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