Skip to Content

← All archived intake epics

Intake: access-control-hardening

breakdown.md

Breakdown: Access-control hardening — apply the declared "own-only" scopes and finish the permission cutover

  • scope-slug: access-control-hardening · source: Jamie's platform-audit notes (2026-08-17) + the web-app feature audit behind them; there is no scope.md behind this batch — technical debt, no approval gate needed (Jamie's instruction). Notes quoted per stub.
  • initiative: Refine the bridge / objective: Q2-2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • personas: Admin, CSM, SDM, Expert, Vendor, Customer

What I understood

The permission templates declare own-scope for customers and experts on leads, proposals, quotes and invoices, but several pages and actions never apply it: the leads board, proposal list, quotes list and invoices list are tenant-wide to anyone who can reach them; the lead workspace has no ownership check at all; proposal create/update has no server-side role gate; a vendor can open any user's profile through the customers page; and the import wizard screen renders for roles whose submit would be rejected. The root cause is the half-finished cutover: the new permission engine runs in shadow mode while most actions still use legacy role lists. This batch closes each exploitable leak with a targeted fix first (stubs 1–5, independently shippable), then finishes the cutover so the two systems can't drift again (stub 6).

Where it sits

Feature-role-matrix entities: leads, proposals, projects, vendors; service-journey steps: lead intake → bid pool → quote → delivery → invoicing. Lands in apps/web (pages, server actions, route policies) and packages/services (list-query scoping parameters).

Build order

  1. workspace-ownership-guard — ownership check on the lead workspace, the highest-impact leak — depends-on: none
  2. leads-board-and-import-scoping — per-persona scoping on the leads board; stop rendering the import wizard to roles that can't submit it — depends-on: none
  3. proposal-access-and-actions — scope the proposal list/detail, gate create/update server-side, align manager accept/reject with the permission registry — depends-on: none
  4. finance-lists-scoping — viewer-scope the quotes and invoices lists on every route; fix the approvals queue bouncing admins — depends-on: none
  5. vendor-customer-profile-guard — restrict /vendor/customers/[id] to the vendor's own customers — depends-on: none
  6. permission-registry-extension — model the unmodelled surfaces (taxonomy, tenant config, onboarding); flip matching route policies to derived — depends-on: none
  7. action-migration-lint-widening — migrate every remaining action to requiredPermission; widen the lint rule to all action files — depends-on: workspace-ownership-guard, leads-board-and-import-scoping, proposal-access-and-actions, finance-lists-scoping, vendor-customer-profile-guard, permission-registry-extension
  8. shim-shadow-removal — delete the allowedRoles shim and shadow mode; centralise own-scope; rule on the deferred brd.view narrowing — depends-on: action-migration-lint-widening

Parallelizable

Stubs 1–5 are mutually independent (disjoint pages/actions) and can run as five parallel sessions; stub 6 is additive (registry + defaults + matching route flips) and independent of them too. Stubs 7–8 are the sequential tail of the old permission-engine-cutover stub, re-cut per Jamie's ruling (2026-08-17: "split into three sequenced stubs before Define"): 7 generalises what 1–6 established and must read their shipped diffs; 8 deletes the legacy path 7 made redundant. Foundation-first tie-break orders 1–5 by blast radius (workspace first).

Out of scope (whole scope)

  • The v1 user hierarchy's inherited-visibility model (managers see their line) — its own pending scope, .icm/intake/v1-user-hierarchy/.
  • New permissions or role changes — this batch applies the scopes already declared, it does not redesign the matrix.
  • Rate limiting / abuse tooling — nothing in the notes asks for it.

_done/action-migration-lint-widening.md

Stub: Migrate every server action to requiredPermission and widen the lint rule

  • feature-slug: action-migration-lint-widening
  • scope: access-control-hardening
  • personas: Admin, CSM, SDM, Expert, Vendor, Customer
  • initiative: Refine the bridge / objective: Q2-2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • depends-on: workspace-ownership-guard, leads-board-and-import-scoping, proposal-access-and-actions, finance-lists-scoping, vendor-customer-profile-guard, permission-registry-extension
  • sequence: 7 of 8

Problem

Part (b) of Jamie's three-way split of the old permission-engine-cutover stub. The require-action-permission lint rule covers a named list of 10 migrated action files; the other ~50 action files still gate on legacy allowedRoles lists, which the engine cannot see and per-tenant template edits cannot affect. Wherever the two systems disagree, the legacy list wins silently.

Proposed change

Migrate every remaining server action to requiredPermission (the registry now models every surface after stub 6), reserving bare resolveActionContext()/authnOnly for genuinely self-scoped reads, each with a stated reason. Extend the require-action-permission lint rule from the named 10-file list to every action file, so an unmigrated action is a lint error, not a silent legacy fallback.

Acceptance criteria (rough)

  • No server action gates on allowedRoles alone; each declares requiredPermission or a documented authnOnly.
  • The lint rule covers every actions.ts/*-actions.ts file in apps/web, not a named list.
  • Behaviour parity everywhere stubs 1–5 didn't deliberately change it — the scope viewer at /admin/settings/scope still reflects reality.
  • Rule Q2 from the original stub honoured: stale "permission-foundation (stub #3)" / "ui-capability-cutover (stub #7)" comment references cleaned up where touched.

Out of scope (this feature)

  • Removing the allowedRoles shim or the shadow logger (stub 8) — during this stub the shim keys stay in place for shadow comparison.
  • Redesigning the permission matrix or adding new capabilities.

Notes for Define

Cut from permission-engine-cutover per Jamie's Q1 ruling (recorded 2026-08-17). Evidence unchanged from the original stub: apps/web/lib/actions/index.ts (three context modes + shadow compare), apps/web/eslint-rules/require-action-permission.mjs + eslint.config.ts (migratedActionFiles). touches: apps/web (most action files), apps/web/eslint-rules/, eslint.config.ts.

_done/finance-lists-scoping.md

Stub: Viewer-scope the quotes and invoices lists; fix the approvals bounce

  • feature-slug: finance-lists-scoping
  • scope: access-control-hardening
  • personas: Customer, Expert, Vendor, Admin, CSM, SDM
  • initiative: Refine the bridge / objective: Q2-2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • depends-on: none
  • sequence: 4 of 8

Problem

The quotes list shows every quote in the tenant to anyone holding invoice.view — all six roles — because the pages never pass the viewer into a service that already supports scoping. The invoices list scopes only experts: a customer at /finances/invoices sees everyone's invoices, while the same data is correctly scoped on the /finances tab — two routes, two behaviours. And /finances/approvals bounces admins to the home page with no message even though its route policy admits them. Jamie's notes: "quotes need to be protected, right now every quote in the tenant is visible to anyone", "the invoice list only scopes experts, a customer can see everynes invoices", "the approvals queue bounces admins to the home page with no message."

Proposed change

Pass the viewer's role and id into every quotes/invoices list read so customer, expert and vendor see only their own documents on every route that lists them; full-scope roles unchanged. Resolve the approvals disagreement one way: either admit admins to the approvals queue (policy stands, page guard goes) or exclude them in the policy so the link never renders — not a silent bounce.

Acceptance criteria (rough)

  • A customer sees identical, own-only invoice and quote lists on /finances, /finances/quotes and /finances/invoices.
  • An expert and a vendor each see only their own documents on all three routes.
  • Detail pages keep their existing role-scoped reads (already correct).
  • An admin clicking into approvals either sees the queue or never sees the link — no redirect-with-no-message path remains.

Out of scope (this feature)

  • The dead search/pagination on these lists — that is a Next 16 params bug (web-correctness-fixes scope, async-params-and-build-errors).
  • Payment processing — later project by Jamie's note; the manual paid-flag flow stands.

Open questions

  • Q1: Should admin be in the approvals queue? The policy (derived from invoice.approve) says yes, the page says csm/sdm only. Jamie to rule when picking this up.

Notes for Define

Evidence: apps/web/components/finances/quotes-list/quotes-list.tsx:35 (accepts userId/role, callers pass neither), apps/web/app/(app)/finances/page.tsx:81, apps/web/app/(app)/finances/invoices/page.tsx:35 (passes role only when expert; the /finances tab scopes customers correctly at finances/page.tsx:28-33), approvals/page.tsx:16-18 (redirects non-csm/sdm). touches: apps/web/app/(app)/finances/, apps/web/components/finances/.

Answers from Jamie — interrogation 2026-08-17

Recorded by the intake-easy-features session; these rulings bind Define.

  • Q1 (approvals queue membership): include admin — align the page with the permission registry (invoice.approve): admin, CSM and SDM all see /finances/approvals; no silent redirect.

_done/leads-board-and-import-scoping.md

Stub: Scope the leads board and the import wizard per persona

  • feature-slug: leads-board-and-import-scoping
  • scope: access-control-hardening
  • personas: Customer, Expert, Vendor, CSM, SDM
  • initiative: Refine the bridge / objective: Q2-2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • depends-on: none
  • sequence: 2 of 8

Problem

/service-leads calls getLeadValueSummary(tenantId) and findByStatuses(tenantId, …) with no owner filter, so any customer or expert who navigates there sees the whole tenant's board — budgets, customer names, assigned experts. The import wizard under the same policy renders its screens (with the tenant's products, services and locations) to customers and experts whose submit is then rejected. Jamie's notes: "the leads board does not filter on a per user basis, all data is exposed" and "onboarding and import wizard is exposed to other user roles."

Proposed change

Apply the viewer's lead.view scope to the board's queries and summary tiles: customers see their own leads, experts see their matched/bid-pool leads, full-scope roles are unchanged. Give /service-leads/import its own route policy matching who can actually submit (admin, CSM, SDM, vendor) instead of inheriting the board's. Verify /onboarding exposure at the same time — its policy is admin+vendor today; confirm no other role can reach it and close any gap found.

Acceptance criteria (rough)

  • A customer's board shows only their own leads, and the value-summary tiles compute from the same scoped set (no tenant-wide totals leaking through the numbers).
  • An expert's board shows only leads visible to them under bid-pool rules.
  • A customer or expert requesting /service-leads/import is redirected by the route policy, never rendered the wizard.
  • Admin, CSM, SDM and vendor board views and import are unchanged.

Out of scope (this feature)

  • Proposal, quote and invoice lists (stubs 3–4).
  • Board redesign, column changes, or new filters — scoping only.

Notes for Define

Evidence: apps/web/app/(app)/service-leads/page.tsx:37-50 (unscoped reads); route policy derives from lead.view, which customer and expert hold at own scope (apps/web/lib/route-policies.ts); import submit-side rejection already exists (apps/web/app/(app)/service-leads/import/actions.ts:85). The scoped-read parameters likely belong on leadService so stub 6 can reuse them. touches: apps/web/app/(app)/service-leads/, packages/services/src/db/services/leads/. Follow the web-route skill for the policy entry.

_done/permission-registry-extension.md

Stub: Extend the permission registry to the unmodelled surfaces

  • feature-slug: permission-registry-extension
  • scope: access-control-hardening
  • personas: Admin, CSM, SDM, Vendor
  • initiative: Refine the bridge / objective: Q2-2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • depends-on: none
  • sequence: 6 of 8

Problem

Part (a) of Jamie's three-way split of the old permission-engine-cutover stub. The registry models only workspace features, so the admin/config surfaces, the taxonomy catalogue and onboarding cannot move off legacy role lists — every one of their routes carries an explicit allow-list with a "no registry home" reason, and their actions can never declare requiredPermission.

Proposed change

Add the missing features to the registry — taxonomy (view/manage), tenantConfig (view/manage), onboarding (run) — grant them in the platform role templates to the roles the routes admit today (csm/sdm: taxonomy + tenantConfig.view; vendor: onboarding.run), realign seeded tenants with a migration in lockstep, and flip every route policy whose derived allow-list matches its old explicit list exactly. Behaviour parity everywhere.

Acceptance criteria (rough)

  • The registry models taxonomy, tenant config and onboarding; the scope viewer and role-template editor render them (they derive from ALL_FEATURES).
  • Template defaults and a role-templates migration move together, so the resolver answers identically from defaults and from seeded tenants.
  • Every flipped route policy derives exactly its previous allow-list (tested).
  • No enforcement change: actions still gate as before (migration of actions is stub 7).

Out of scope (this feature)

  • Migrating any server action to requiredPermission (stub 7).
  • Removing the allowedRoles shim or shadow mode (stub 8).
  • Deriving /users and /activity — their pages render full-tenant PII unscoped, so deriving from people.view/activity.view (held at own by customer/expert) would widen access; they stay explicit until page-level scoping.

Notes for Define

Cut from permission-engine-cutover per Jamie's Q1 ruling (recorded 2026-08-17): "split into three sequenced stubs before Define — (a) registry extension for unmodelled surfaces, (b) action migration + lint widening, (c) shim/shadow removal." touches: packages/services/src/permissions/{registry,defaults}.ts, packages/services/src/db/migrations/, apps/web/lib/route-policies.ts.

_done/proposal-access-and-actions.md

Stub: Protect proposal reads and gate proposal writes

  • feature-slug: proposal-access-and-actions
  • scope: access-control-hardening
  • personas: Expert, Customer, CSM, SDM
  • initiative: Refine the bridge / objective: Q2-2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • depends-on: none
  • sequence: 3 of 8

Problem

Three related holes on one entity. (1) The proposal list and detail are open to everyone in the tenant — an expert can read rival bids (price, title, bidder) on every lead, and customers can too; ownership only gates the Edit button. (2) createProposal and updateProposal run on a bare resolveActionContext() — no role, no permission — so any signed-in user can create or edit a proposal, and the non-expert create page lists 100 tenant leads in a picker. (3) The manager accept/reject actions gate on a legacy role list while the button that shows them gates on proposal.respond — two sources of truth that already disagree. Jamie's notes: "proposal list is entirely unprotected and open to everyone", "no role check for proposal CRUD", "the proposal accept and reject flows have been slightly compromised."

Proposed change

Scope the list and detail to the viewer: experts see their own proposals, customers see proposals submitted on their own leads (per the docs' ranked-proposals promise), managers see all. Gate create/update/delete server-side as expert-only (plus the existing bid-pool match check), and drop the all-leads picker for non-experts. Re-gate manager accept/reject on the permission registry so lever and action agree — resolving the registry's current proposal.respond holder list against the intended behaviour.

Acceptance criteria (rough)

  • An expert's proposal list contains only their own proposals; a rival expert's bid is unreachable by list or direct URL.
  • A customer sees only proposals on their own leads, and only once submitted (drafts stay invisible, as the docs promise).
  • A customer or vendor calling the create/update proposal action directly gets a rejection.
  • Manager accept/reject works for exactly the roles the permission registry names, and the visible lever and the action's gate share one source of truth.

Out of scope (this feature)

  • The negotiation loop (lead-lifecycle-automation scope).
  • AI proposal scoring (ai-platform-enablement scope).
  • One-proposal-per-expert index or draft mechanics — already correct.

Open questions

  • Q1: The registry grants proposal.respond to sdm, expert and customer — should CSM hold it too (the legacy action list says admin/csm/sdm)? Settle the holder list before re-gating.

Notes for Define

Evidence: apps/web/components/proposals/proposals-list/proposals-list.tsx:11-16 (unscoped findAll), apps/web/app/(app)/proposals/page.tsx:25 (never passes expertId), proposal-detail.tsx:102-114 (ownership gates Edit only), apps/web/app/(app)/proposals/create/actions.ts:96,147 (bare context), create/page.tsx:77 (all-leads picker), proposal-detail/actions.ts:257,293 (legacy role list vs proposal.respond). touches: apps/web/app/(app)/proposals/, apps/web/components/proposals/. Follow the server-action skill's three-step pipeline.

Answers from Jamie — interrogation 2026-08-17

Recorded by the intake-easy-features session; these rulings bind Define.

  • Q1 (proposal.respond holders): admin + CSM + SDM only — match the legacy action list; the registry's expert/customer grants on proposal.respond are removed when it becomes the single source of truth. Experts/customers act through their own submit/accept flows, not proposal.respond.

_done/shim-shadow-removal.md

Stub: Remove the allowedRoles shim and end shadow mode

  • feature-slug: shim-shadow-removal
  • scope: access-control-hardening
  • personas: Admin, CSM, SDM, Expert, Vendor, Customer
  • initiative: Refine the bridge / objective: Q2-2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • depends-on: action-migration-lint-widening
  • sequence: 8 of 8

Problem

Part (c) of Jamie's three-way split of the old permission-engine-cutover stub. Once every action enforces on the resolver (stub 7), the legacy allowedRoles option and the shadow-mode logger are dead weight: two code paths that can only drift from the enforced one.

Proposed change

Delete the allowedRoles option from resolveActionContext and every remaining reference, remove logShadowPermissionCheck and the permissions/shadow.ts module, and make own-scope enforcement a resolver/service concern rather than per-page code where stubs 1–5 left tactical guards that the central path now subsumes. Revisit the brd.view full-scope template defaults for expert/customer — the narrowing stub 1 deferred here on Jamie's ruling.

Acceptance criteria (rough)

  • resolveActionContext accepts only requiredPermission/authnOnly (+ onboarding gate); the shim and the shadow logger are deleted.
  • The workspace lever/action wiring reads the same permission source as the actions (no repeat of the proposal accept/reject drift).
  • brd.view defaults for expert/customer ruled on and applied (with a template migration if narrowed).
  • The scope viewer at /admin/settings/scope still reflects reality after the removal.

Out of scope (this feature)

  • The v1 user hierarchy's manager-visibility model (.icm/intake/v1-user-hierarchy/).
  • New permissions or role changes.

Notes for Define

Cut from permission-engine-cutover per Jamie's Q1 ruling (recorded 2026-08-17). Evidence: apps/web/lib/actions/index.ts, packages/services/src/permissions/shadow.ts, defaults.ts (brd.view full for expert/customer — deferred narrowing from workspace-ownership-guard). touches: apps/web/lib/actions/, packages/services/src/permissions/.

_done/vendor-customer-profile-guard.md

Stub: Restrict the vendor customer-profile page to actual customers

  • feature-slug: vendor-customer-profile-guard
  • scope: access-control-hardening
  • personas: Vendor
  • initiative: Refine the bridge / objective: Q2-2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • depends-on: none
  • sequence: 5 of 8

Problem

/vendor/customers/[id] mounts the generic user profile with the raw route id and no assertion that the target is a customer — or the vendor's customer. A vendor can read any tenant user's PII (email, phone, address, last login), including admins and experts, and the profile edit form renders even though its action would reject. The /users directory deliberately excludes vendors for exactly this reason; this route goes around it. Jamie's note: "a vendor can open any user's profile through their customers page. fix this."

Proposed change

Guard the page: the id must resolve to a customer user, and one with a relationship to the requesting vendor (leads/engagements with that vendor), else 404. Render a vendor-appropriate read-only view — a customer relationship card, not the internal staff profile with its edit affordances.

Acceptance criteria (rough)

  • A vendor opening an admin's, expert's, CSM's or SDM's user id under /vendor/customers/ gets a 404.
  • A vendor opening a customer they have no engagement with gets a 404.
  • A vendor's own customer renders without edit controls, showing only relationship-relevant fields.
  • /users access for staff roles is unchanged.

Out of scope (this feature)

  • Vendor customer-list contents (already scoped to the vendor's top customers).
  • Customer health/churn analytics on that page — vendor-metrics scope owns those.

Notes for Define

Evidence: /vendor/customers/[id] renders the generic UserProfile with no is-a-customer check; apps/web/lib/route-policies.ts:68 documents why vendors are excluded from /users (REASON_FULL_TENANT_PII). Define what "the vendor's customer" means precisely (customer on any lead where vendor = this vendor is the natural read). touches: apps/web/app/(app)/vendor/customers/.

Answers from Jamie — interrogation 2026-08-17

Recorded by the intake-easy-features session; these rulings bind Define.

  • Definition of "the vendor's customer": the customer party on any lead/engagement where vendor = this vendor, any status — the natural read the stub suggested.

_done/workspace-ownership-guard.md

Stub: Ownership check on the lead workspace

  • feature-slug: workspace-ownership-guard
  • scope: access-control-hardening
  • personas: Customer, Expert
  • initiative: Refine the bridge / objective: Q2-2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • depends-on: none
  • sequence: 1 of 8

Problem

Opening /workspace/[id] is not ownership-checked: the page loads any lead in the tenant by id and only 404s when it doesn't exist. lead.view is own-scoped for customer and expert, but that scope is applied only to the proposals satellite — so any customer or expert can open any other customer's project and read its BRD, quote, milestones, invoices, blockers and activity. Jamie's note: "workspace url is not ownership checked, we need to ensure workspaces are protected." This is the highest-impact leak in the audit.

Proposed change

Enforce the viewer's declared lead.view scope at workspace load: a customer or expert whose scope is own gets notFound() (not a redirect that confirms existence) on any lead they are not a party to; full-scope roles (admin, CSM, SDM) are unchanged. The same check must cover every workspace satellite read and every workspace server action, not just the page shell — one shared guard, not per-satellite copies.

Acceptance criteria (rough)

  • A customer opening another customer's workspace URL gets a 404; their own workspace still renders fully.
  • An expert can open only leads they are matched to / party to (bid-pool visibility rules unchanged); everything else 404s.
  • Workspace server actions (levers) reject the same non-parties server-side, even when called directly.
  • Admin, CSM and SDM access is unchanged, and View As emulation still resolves as the emulated user.
  • brd.view reads through the same guard, closing the compounding leak (the BRD section was visible on any lead a persona could open).

Out of scope (this feature)

  • Scoping the list surfaces that link into the workspace (stubs 2–4).
  • Changing which sections each persona sees inside a workspace they legitimately own.

Notes for Define

Evidence: apps/web/app/(app)/workspace/[id]/page.tsx + apps/web/lib/queries/workspace.ts:130 (loads by leadService.findById(tenantId, leadId), no party check; proposals-only scoping at workspace.ts:281-296). The aggregate cache key already includes viewerId, so a viewer-aware guard fits the existing cache shape. PLATFORM_ROLE_TEMPLATE_DEFAULTS grants brd.view at full to expert and customer (packages/services/src/permissions/defaults.ts:110,146) — Define should decide whether to narrow that default here or lean on the lead guard alone (narrowing belongs to stub 6 if deferred). touches: apps/web/app/(app)/workspace/, apps/web/lib/queries/workspace.ts, apps/web/components/workspace/actions.ts. Follow the web-route and server-action skills' patterns.

Answers from Jamie — interrogation 2026-08-17

Recorded by the intake-easy-features session; these rulings bind Define.

  • brd.view template defaults for expert/customer: defer the narrowing to permission-engine-cutover — this stub leans on the lead-ownership guard alone.