Skip to content

EPIC R — Scoped Policy Authorship #108

Description

@haydercyber

Summary

Adds project-scoped policy rule authoring so section heads can author non-prod policy for their own projects without holding the global policy.edit permission. Platform retains full control over global rules + production policy + the priority band 9000+; scoped users can only CRUD their project's non-prod rules below the band.

Same pattern as EPIC Q (#99 — scoped provider connection bindings) extended to a different permission family.

Why

Today policy.edit is a single global permission. Every holder can create, edit, or delete any policy rule, affecting any project's request-to-workflow resolution. With the section-head model (EPIC L Slice 2 + EPIC N + EPIC Q), platform should let section heads author rules for their own projects without giving them the ability to change tenant-b's approval workflow.

Locked design decisions

Q1  — project_id column on policy_rules; NULL = platform-owned (admin only)
Q2  — new policy.author permission; policy.edit unchanged
Q3  — scoped rules non-prod-only by construction (DB CHECK + service)
Q4  — DB CHECK enforces env_kind=non_prod OR env_id present; service
      validates env_id → env.kind = non_prod AND env_id in project
Q5  — 8 stable error codes (renamed for consistency with EPIC Q)
Q6  — policy.denied_out_of_scope audit event; NO policy_rule_id
Q7  — separate Create/Update/Delete methods (admin) vs
      *ForScopedAuthor (scoped); not unified by a callerScope arg
Q8  — 6 resolver tests + 1 DB defense-in-depth test + scoped env_id
      to prod env service test
Q9  — UpdateScopedPolicyInput selector: nil preserves;
      explicit {} REJECTED for scoped authors
Q10 — scoped GET list shows inherited platform rules with badge +
      sanitized projection (no raw selector values)
Q11 — 4 Prometheus counters (created / updated / deleted / denied)
      with low-cardinality labels; NO ids on labels
Q12 — policy_scope_too_broad.reason carries 4 variants
Q13 — sidebar entry gated by policy.author; auto-route on single
      project coverage
Q14 — guided form only for scoped authors; no raw JSON
Q15 — empty-state admin shortcut visible only to policy.edit holders
Q16 — extend docs/operations/policy-templates.md (NOT a new page)
Q17 — 5-slice PR breakdown (no worker slice)

Mid-pass corrections (each baked into the locked spec):

  • §2 — resolver must filter by policy_rules.project_id (not just authorization boundary — rule-matching boundary too). Request without project_id in scope considers ONLY platform/global rules.
  • §3 — empty selector {} REJECTED for scoped authors (not just preserved on update). An empty selector is effectively broad/global and could match prod.
  • §4 — inherited platform projection sanitized server-side. Scoped users see selector_keys + name + priority + workflow_name + enabled + is_platform_inherited=true; never raw selector values.
  • §4 — routes must be behind authenticated session middleware. Service runs the team-aware coverage inline; auth identity must already be in context.
  • §5 — policy errors in their OWN module (src/api/policyErrors.ts or src/api/projectPolicyRules.ts), NOT mixed into providerConnections.ts.
  • §5 — policy.edit does NOT imply policy.author (NEVER server-side, NEVER as a UI shortcut). The two permissions stay distinct everywhere.
  • §5 — Workflow dropdown filters defensively (enabled + non-system in v1); future workflow_definitions.scoped_policy_authorable flag tracked as follow-up [Step 4] Scaffold Fiber API + health/ready/metrics + middleware placeholders #1.

Hardcoded constants:

PlatformReservedPriority = 9000

Scoped policy.author rules must use priority < 9000; platform policy.edit rules use >= 9000 for guaranteed precedence.

Schema (migrations 0033 + 0034)

-- 0033: scoping column + CHECK constraints
ALTER TABLE policy_rules
    ADD COLUMN project_id UUID NULL REFERENCES projects(id) ON DELETE CASCADE;

CREATE INDEX policy_rules_project_id_idx
    ON policy_rules (project_id)
    WHERE project_id IS NOT NULL;

ALTER TABLE policy_rules
    ADD CONSTRAINT policy_rules_selector_project_matches_column
    CHECK (
        project_id IS NULL
        OR NOT (selector ? 'project_id')
        OR selector->>'project_id' = project_id::text
    );

ALTER TABLE policy_rules
    ADD CONSTRAINT policy_rules_scoped_requires_env
    CHECK (
        project_id IS NULL
        OR (
            (selector ? 'environment_kind' AND selector->>'environment_kind' = 'non_prod')
            OR (selector ? 'environment_id')
        )
    );

-- 0034: policy_author seed role
INSERT INTO roles (name, description, permissions, is_system)
VALUES ('policy_author', '...', '["policy.author"]'::jsonb, true)
ON CONFLICT (name) DO NOTHING;

Sub-issues

  • R1 — schema + storage + service + permission catalog
  • R2 — handlers + envelope + observability
  • R3 (ui) — /projects/:id/policies + author drawer + capability helpers
  • R4 (docs) — scoped policy authoring operator guide additions
  • R5 — EPIC R skills round-trip

R-follow-ups (NOT v1 blockers)

Stable error codes (8)

policy_not_found                       404
platform_policy_not_editable           403
out_of_scope_policy                    403
policy_selector_mismatch               400
prod_policy_not_allowed_for_scope      403  (envelope adds env_kind)
policy_scope_too_broad                 400  (envelope adds reason: env_constraint_missing|env_kind_invalid|selector_empty|env_kind_id_inconsistent)
policy_priority_reserved               400  (envelope adds cap: 9000)
policy_environment_not_in_project      400

Audit events

  • policy.create / policy.update / policy.delete — selector KEYS only, NEVER values
  • policy.denied_out_of_scope — does NOT include policy_rule_id (gate-order protection)

Observability

4 Prometheus counters with the LOW-CARDINALITY LOCK (no ids on labels):

policy_rules_created_total{permission_used, scope}
policy_rules_updated_total{permission_used, scope}
policy_rules_deleted_total{permission_used, scope}
policy_rules_denied_total{reason}

scope ∈ {platform, project}. reason fixed 8-element closed set.

Resolver test plan

6 tests pinned for R1:

  1. PlatformGlobalRulesStillMatch
  2. ScopedRuleMatchesItsOwnProject
  3. ScopedRuleA_NeverMatchesProjectB
  4. RequestWithoutProjectID_LoadsOnlyPlatformRules
  5. PlatformReservedBandWinsOverScoped
  6. ScopedRuleWithProdSelector_CannotBeInserted (DB CHECK defense-in-depth)

Plus the service-level test: scoped author cannot create a selector with environment_id pointing to a prod env.

Dependencies

Definition of done

  • R1–R5 merged
  • All 5 follow-ups filed with intentional priority labels
  • EPIC R skills round-trip merged
  • Operator guide live on docs site

Metadata

Metadata

Assignees

No one assigned

    Labels

    epicTracking issue spanning multiple PRspriority/p1Should-have; post-MVP soon

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions