# Phase 4 — Permissions & policies **Status:** ⬜ Not started — depends on Phases 1–3 (all collections must exist before policies are drafted) **Outcome:** Every collection × action combination has an explicit Directus 11 Policy attached. Multi-tenant isolation is enforced by Directus's dynamic-filter mechanism, not by application code. Operators see only data scoped to orgs they belong to, with the actions allowed by their role. Non-admin users can register entries, view live tracking, review their own results — all without ever needing admin role. ## Why this is a separate phase - **Premature policy commitment is expensive.** Defining policies before the data model has shaken out leads to filters that break when a collection's shape changes. Phases 1–3 get one to two iterations on the schema; Phase 4 lands when the model is stable. - **Policy filters are tedious but not architectural.** This is admin-UI configuration work, not design. Roughly 5 roles × ~20 collections × 4 CRUD actions = ~400 (role × collection × action) cells, most of which are templated repeats of "user is in this org via `organization_users`". - **Testable as a unit.** End-state: a non-admin test user with `participant` role can perform exactly the operations they should, and zero others. Phase 4's CI / dogfood verification is a permission-boundary test suite. ## Roles to support (per [[directus-schema-draft]]) | Role | Power | |---|---| | `org-admin` | Full CRUD within their org. Can manage `organization_users`, classes, events. | | `race-director` | Manage entries, segments, geofences, penalties for events in their org. Approve / publish stage results. Cannot create new orgs. | | `marshal` | Read-only on most collections; can flag faulty positions and write notes on entries during the event. Time-limited (only during active event). | | `timekeeper` | Edit `entry_segment_starts.target_at` (late-arrival reseeding); read all entries; cannot modify penalties. | | `participant` | Read-only on entries they appear in (via `entry_crew`); read on the events they're registered for; no writes. | | `viewer` | Read-only on public-facing event data (live map, published results). Lowest privilege; default for any user not otherwise scoped. | ## Tasks (sketched, not detailed) | # | Task | Notes | |---|------|-------| | 4.1 | Draft the canonical "user is in this org with this role" filter expression | One JSON filter that gets reused. Lives in a template / snippet for copy-paste. | | 4.2 | `org-admin` policy | All CRUD on org-scoped collections, scoped via the canonical filter. | | 4.3 | `race-director` policy | CRUD on events / entries / classes / penalties for events in their org. | | 4.4 | `marshal` policy | Field-level write on `positions.faulty`; entry notes; otherwise read-only. | | 4.5 | `timekeeper` policy | Field-level write on `entry_segment_starts.target_at` and `manual_override`; otherwise read-only. | | 4.6 | `participant` policy | Filter on entries via `entry_crew.user_id = $CURRENT_USER`. | | 4.7 | `viewer` policy | Public read on a curated subset (live positions for active events, published `stage_results`). | | 4.8 | Snapshot regeneration + CI verification | All policies round-trip via `directus schema snapshot` (verify the format is faithful — Directus's policy serialization has historically been finicky). | | 4.9 | Permission-boundary test suite | Custom test that creates a user per role, attempts a series of CRUD operations, asserts allowed/denied per a fixture. Runs in CI alongside the dry-run. | ## Open questions blocking task-level detail 1. **Marshal time-limiting.** Marshal access tied to "during active event" — does Directus's dynamic filter support time-bounded conditions natively, or does this need a custom hook (Phase 5)? 2. **Field-level vs row-level restrictions.** Some collections (`positions`, `entry_segment_starts`) need field-level write restrictions (only one column writable). Verify Directus 11 supports field-level policies in the dynamic-filter mechanism, or fall back to a hook that rejects writes to other fields. 3. **Snapshot fidelity.** Does `directus schema snapshot` faithfully capture all policy filter JSON? If not, policies might need to live in a separate seed script applied alongside the snapshot.