Plan: Multi-Jurisdictional Authorization
On this page
- Status
- Context
- Threat model
- Scope
- Design
- D1. Identity normalization (destructive migration)
- D2. Worker identities table (IdP-neutral)
- D3. Policy engine on zen-engine
- D4. RMQ-driven cache invalidation
- D5. Default rulesets (JDM JSON files)
- D6. Cross-service denormalization
- D7. Handler integration pattern
- D8. HTTP verb to Action mapping
- D9. Soft-delete behavior
- D10. Test coverage (L1 + L2 + L3)
- D11. Jurisdiction readiness
- D12. ADRs
- D13. Service-to-service authorization
- D14. Audit logging schema
- D15. Bulk operation authz
- D16. Custom claims source for JDM evaluation context
- D17. CI authz coverage gate
- D18. Missing-policy detection at boot
- Steps
- Step 1: Plan filing + 5 ADR drafts + GitLab issue tree
- Step 2: ADR-025 acceptance + identity normalization
- Step 3: Worker identities table + IdP-neutral autocomplete
- Step 4: ADR-023/024/026/027 acceptance + craig-authz crate skeleton
- Step 5: RMQ cache invalidation + bulk-fetch endpoint
- Step 6: Default authz rulesets (Georgia + Texas)
- Step 7: Cross-service denormalization
- Step 8: Cases service engine adoption
- Step 9: Placement + exchange engine adoption
- Step 10: Financial + reporting engine adoption
- Step 11: Security + rules engine adoption
- Step 12: Jurisdiction-readiness Phase A — defaults removal
- Step 13: Jurisdiction-readiness Phase B — admin units + i18n + operational constants
- Step 14: Plan completion audit + archive
- Files Touched (aggregate)
- Verification (overall)
- Documentation Updates
- Errata
- Step 13b deviations (2026-05-10)
- Step 13a deviations (2026-05-10)
- Step 11 deviations (2026-05-10)
- Step 10 deviations (2026-05-10)
- Step 9 deviations (2026-05-09)
- Step 8b deviations (2026-05-09)
- Step 8c — deferred handler modules (planned)
- Step 8 deviations (2026-05-09)
- Step 7 deviations (2026-05-09)
- Step 6 deviations (2026-05-09)
- Step 5 deviations (2026-05-09)
- Open questions
- Risks
- After this plan lands
Status
| Step | Description | Status |
|---|---|---|
1 |
Plan filing + 5 ADR drafts (ADR-023 multi-jurisdictional authz; ADR-024 zen-engine policy engine + RMQ cache invalidation; ADR-025 identity normalization preferred_username → sub UUID; ADR-026 IdP-neutral identity layer; ADR-027 architectural principles — pluggability, async-first, identity-neutral, configuration-as-data) + GitLab epic + step issues. nav.adoc Active entry. CHANGELOG entry. No code changes. |
Done (pre-ADR-030) |
2 |
ADR-025 acceptance + identity normalization. Destructive ALTER on |
Done (pre-ADR-030) |
3 |
Worker identities table — |
Done (pre-ADR-030) |
4 |
ADR-023 + ADR-024 + ADR-026 + ADR-027 acceptance + new |
Done (pre-ADR-030) |
5 |
RMQ-driven cache invalidation. craig-rules publishes |
Done (pre-ADR-030) |
6 |
Default authz rulesets — JDM JSON files in |
Done (pre-ADR-030) |
7 |
Cross-service denormalization. ALTER ADD |
Done (pre-ADR-030) |
8 |
Cases service adoption: wire engine into ~57 handlers; replace |
Done (pre-ADR-030) |
9 |
Placement + exchange rollout: ~42 handler sites. Engine wired; default policies cover both services; L2/L3 tests follow Step 8’s pattern. |
Done (pre-ADR-030) |
10 |
Financial + reporting rollout: ~28 handler sites. |
Done (pre-ADR-030) |
11 |
Security + rules rollout: ~12 handler sites. (Many security handlers admin-only; lighter touch.) |
Done (pre-ADR-030) |
12 |
Jurisdiction-readiness Phase A: remove Georgia hardcoded defaults in |
Done (pre-ADR-030) |
13 |
Jurisdiction-readiness Phase B: externalize Georgia counties from |
Done (pre-ADR-030) |
14 |
Plan completion audit + archive. Spawn audit subagent per delivery-protocol.md; flip Status all-Complete; move plan from |
Done (pre-ADR-030) |
Epic: TBD (filed by Step 1)
Issues: TBD (14 step-tracking issues filed by Step 1)
Branch prefix: feat/multi-juris-authz- / fix/multi-juris-authz- / chore/multi-juris-authz-
*Milestone: TBD
Context
The 2026-04-21 audit flagged record-level authorization (BOLA/IDOR) as the headline P0 federal-compliance event. CRAIG’s caseworker JWTs carry the caseworker role and an opaque sub claim; handlers verify the role but not whether claims.sub is authorized to read THIS PARTICULAR ROW. Platform Stabilization Phase 2 (epic &21, archived 2026-05-07) closed concurrency-correctness but explicitly scoped out application-layer authorization. This plan closes that gap.
Two architectural pivots happened during plan-shaping (2026-05-07 design conversation):
-
Hardcoding any specific authz policy is wrong for a multi-jurisdictional CCWIS. A jurisdiction-coupling audit found that hardcoding "supervisors are realm-flat" (or any other policy choice) forces every adopting jurisdiction to either fork CRAIG or eat that policy. Each jurisdiction must define its own roles + scoping rules in data, not code. This pivot drives the policy engine + jurisdiction-readiness scope of this plan.
-
Don’t reinvent the policy DSL. CRAIG already uses zen-engine (gorules' JDM evaluator) for
{jurisdiction}-screening-policy,{jurisdiction}-person-match,{jurisdiction}-safety-assessmentrulesets. The 2026-05-07 audit identified this as the codebase’s gold-standard pattern. Authz policies join the same pattern:{jurisdiction}-authz-{resource}rulesets evaluated via zen-engine. No new DSL, no new parser, no new evaluator — reuses existing primitives.
A third commitment surfaced during shaping: IdP neutrality (ADR-026). CRAIG must work with any deployer’s identity backend (Keycloak / Auth0 / Okta / AD / etc.) without reaching for IdP-specific admin APIs. Username → sub lookups (for BFF reassignment autocomplete) are served by CRAIG’s own worker_identities table, populated lazily from JWT claims.
Related ADRs (existing): ADR-022 (Event Durability + Idempotency — outbox/inbox foundation), ADR-013 (Web Session Strategy), ADR-014 (Shared reqwest::Client). New ADRs filed by this plan: ADR-023, ADR-024, ADR-025, ADR-026, ADR-027.
Threat model
Trust boundary: every authenticated request entering CRAIG passes through crates/craig-auth/src/jwks.rs::JwksProvider::validate_token (issuer + audience + expiry + JWKS signature check + typ == Bearer). The policy engine sits behind that boundary — it assumes the JWT is genuine and the claims are accurate. Anything upstream of validation is the IdP’s threat surface, not CRAIG’s.
In scope (this plan must mitigate):
-
Authenticated horizontal-privilege escalation (BOLA/IDOR): a caseworker with a valid JWT reads/mutates rows assigned to a different caseworker. Mitigation: record-level predicate evaluated for every row-touching handler (Steps 8-11).
-
Authenticated query-parameter manipulation: caller passes
?worker=other-subto a LIST endpoint and bypasses scoping. Mitigation: engine determines LIST scope viaauto_scope_list; caller-supplied scope params ignored. BFF stops sending the param (Step 13). -
Authenticated soft-delete-existence-bypass: caller GETs a soft-deleted row by ID to learn it existed. Mitigation: predicate runs before existence check; non-authorized callers get 403 regardless of
activeflag (§D9). -
Authenticated cross-jurisdiction read: a user authorized in Georgia reads a Texas row. Mitigation: jurisdiction is per-deployment-instance today (network isolation); engine evaluates
(jurisdiction, role, resource_type, action)per request as defense-in-depth. -
Cache-coherence drift between policy edit and enforcement: admin edits a deny-policy; a replica still has the old (allow) policy cached. Mitigation: RMQ fan-out invalidation + TTL fallback (§D4). Acceptable stale window: ≤ TTL (1h default).
Out of scope (mitigations live elsewhere or are accepted as residual risk):
-
IdP compromise: if the IdP is compromised, attackers can mint JWTs with arbitrary claims. Defense: IdP hardening — a per-deployment concern, not CRAIG’s.
-
Insider supervisor collusion: a supervisor with
scope: allfor the relevant resource colludes with the requester. Mitigation: audit log + retrospective forensics (§D15); not preventable at the predicate layer. -
Infrastructure-level cache poisoning: an attacker writes directly into a service’s in-memory cache or RabbitMQ. Defense: RMQ ACLs, DB credentials least-privilege, k8s NetworkPolicy. Not policy-engine concerns.
-
TOCTOU between cache-fetch and predicate evaluation: a policy could change in the millisecond between fetch and eval. Accepted: snapshot taken at request time; effect bounded by single-request scope.
-
Federal compliance read-audit completeness: the engine emits
authz.access_deniedevents but doesn’t capture every successfulreaddecision. Read-audit coverage is a separate plan (audit-trail-completeness.adoc, future).
Attacker capabilities assumed:
-
Has a valid JWT with at least one CRAIG role
-
Knows valid resource IDs (UUIDs are not access-control)
-
Can craft arbitrary HTTP requests (query params, body, headers)
-
Cannot mint or modify JWTs (presumes IdP integrity)
-
Cannot directly write to the authz_policies cache, RMQ, or DB
Scope
In scope:
-
Identity normalization (Step 2): destructive migration of
cases.{cases.assigned_worker, cases.supervisor, investigations.assigned_worker}from preferred_username TEXT to sub UUID. No backfill (pre-1.0; no production data; devstack reseed regenerates). -
Worker identities table (Step 3): new
worker_identitiesin craig-security; auth-middleware lazy-upsert from JWT claims;GET /v1/security/workers?search=endpoint for BFF autocomplete. -
Policy engine (Step 4): new
crates/craig-authzcrate wrapping zen-engine; loads JDM rulesets via craig-rules client; in-memory cache; fail-closed on missing policy or cache miss. -
Cache invalidation (Step 5): RMQ fan-out via
subscribe_exclusive+ TTL fallback (1-hour default with jitter); bulk-fetch endpoint in craig-rules for boot. -
Default rulesets (Step 6): JDM JSON for Georgia + Texas, deliberately divergent for L3 regression coverage; seeded via existing mechanism.
-
Cross-service ownership data (Step 7): denormalize
assigned_worker_subonto 11 tables;case.assignment_changedevents on outbox/inbox. -
Per-service engine adoption (Steps 8-11): ~140 handler sites. Action vocabulary
{ Read, List, Create, Update, Delete, Approve }. Soft-delete: predicate first. -
Jurisdiction readiness (Steps 12-13): remove Georgia hardcoded defaults; externalize Georgia counties; seed Texas counties (closes #225); BFF/intake i18n hardcodes (5 P0 items); ICPC + payment-period externalization.
Out of scope (deferred):
-
HKDF blind-index, JWS replay defense, intake hardening (
Uuid::nil()sentinel,HashMapconstant-time, error-detail leakage), strum enum-boundary continuation,pub(crate)discipline, test silent-skip lint,cargo denyRUSTSEC ignore audit → Plan B (pii-and-partner-edge-hardening.adoc) -
GitOps-style jurisdiction config (gitfs-pulled policies/rulesets/branding/locales) → separate plan (
gitops-jurisdiction-config.adoc); Plan A’s file format is gitfs-friendly so future migration is clean -
Constituent / foster parent / provider portals (Phase 11) — depends on this plan
-
Mobile / offline client (ADR-009) — depends on this plan
-
Tribal language i18n — separate plan once a tribal jurisdiction adopts
-
Sealed/confidential record patterns — no design surface today; punt
-
Supervisor-chain authorization — no supervisor-chain table; punt to a future plan
Design
D1. Identity normalization (destructive migration)
Three columns affected:
| Table | Column | Current type | New type |
|---|---|---|---|
cases.cases |
assigned_worker |
TEXT NOT NULL (preferred_username) |
UUID NOT NULL (sub) |
cases.cases |
supervisor |
TEXT NULL (preferred_username) |
UUID NULL (sub) |
cases.investigations |
assigned_worker |
TEXT NOT NULL (preferred_username) |
UUID NOT NULL (sub) |
Single migration, destructive shape:
ALTER TABLE cases.cases DROP COLUMN assigned_worker;
ALTER TABLE cases.cases ADD COLUMN assigned_worker UUID NOT NULL;
ALTER TABLE cases.cases DROP COLUMN supervisor;
ALTER TABLE cases.cases ADD COLUMN supervisor UUID NULL;
ALTER TABLE cases.investigations DROP COLUMN assigned_worker;
ALTER TABLE cases.investigations ADD COLUMN assigned_worker UUID NOT NULL;
CREATE INDEX idx_cases_assigned_worker ON cases.cases (assigned_worker);
CREATE INDEX idx_cases_supervisor ON cases.cases (supervisor) WHERE supervisor IS NOT NULL;
CREATE INDEX idx_investigations_assigned_worker ON cases.investigations (assigned_worker);
Pre-1.0 + no production data justifies destructive shape. Devstack/test data regenerated via cargo xtask dev reseed. First-production deployments use per-jurisdiction data-import tools (out of scope here).
Existing seed-generator + integration-test fixtures (in tools/craig-seed/src/datagen.rs and per-service tests/api/) currently emit display-name strings ("bob.smith"); these become claims.sub UUIDs ("00000000-0000-0000-0000-000000000002"). Test fixtures iterate accordingly.
D2. Worker identities table (IdP-neutral)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE worker_identities (
sub UUID PRIMARY KEY,
preferred_username TEXT NOT NULL,
display_name TEXT NOT NULL,
email TEXT,
attrs JSONB NOT NULL DEFAULT '{}',
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(preferred_username)
);
CREATE INDEX idx_worker_identities_username_trgm ON worker_identities USING gin (preferred_username gin_trgm_ops);
CREATE INDEX idx_worker_identities_display_name_trgm ON worker_identities USING gin (display_name gin_trgm_ops);
CREATE INDEX idx_worker_identities_attrs ON worker_identities USING gin (attrs jsonb_path_ops);
(Trigram indexes for fuzzy autocomplete search; jsonb_path_ops for attrs filtering. pg_trgm is a standard Postgres extension; pg_trgm and jsonb_ops ship with every Postgres install.)
The attrs column captures non-standard JWT claims for per-jurisdiction policy expressions — see §D16 for details.
Lives in craig-security. Auth middleware in crates/craig-auth/src/middleware.rs::JwtMiddleware::on_request upserts on every authenticated request:
async fn upsert_worker_identity(
db: &PgPool,
claims: &Claims,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"INSERT INTO worker_identities (sub, preferred_username, display_name, email, last_seen_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (sub) DO UPDATE SET
preferred_username = EXCLUDED.preferred_username,
display_name = EXCLUDED.display_name,
email = EXCLUDED.email,
last_seen_at = now()"#,
claims.sub_uuid()?,
claims.preferred_username,
claims.display_name(), // helper that falls back to preferred_username if name claim absent
claims.email,
)
.execute(db)
.await?;
Ok(())
}
Performance: this runs per-request; expect 1-2ms median. Indexed on PK. ON CONFLICT path is the common case after warmup.
New endpoint: GET /v1/security/workers?search=<q>&limit=N (default limit 25, max 100). Case-insensitive fuzzy search on preferred_username + display_name. Returns Vec<WorkerIdentitySummary { sub, preferred_username, display_name }>. Auth: require_caseworker_or_above (anyone making decisions about assignment needs to find users).
D3. Policy engine on zen-engine
crates/craig-authz (new). Public surface in src/lib.rs:
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use craig_auth::Claims;
use craig_common::error::ApiError;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use strum::{Display, EnumString};
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct ResourceRef<'a> {
pub resource_type: ResourceType,
pub resource_id: Uuid,
pub assigned_worker_sub: Option<Uuid>,
pub supervisor_sub: Option<Uuid>,
pub jurisdiction: &'a str,
/// Extra fields available to policy expressions (icwa_flag, admin_unit, status, etc.)
pub attrs: HashMap<&'a str, Value>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, EnumString, Display, Serialize, Deserialize)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum Action { Read, List, Create, Update, Delete, Approve }
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, EnumString, Display, Serialize, Deserialize)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum ResourceType {
Case, Investigation, Referral, Report, Contact, ContactAttachment,
CourtOrder, CasePlan, CasePlanTask, Person, ReportPerson,
ReportAttachment, ScreeningDecision, DispositionFollowUp, HouseholdMember,
Placement, FosterHome, EducationRecord, HealthRecord, KinshipOption, HomeDocument,
ExchangeAgreement, ExchangePartner, ExchangeTransaction,
IcpcRequest, IcpcHomeStudy, IcpcAttachment,
Payment, PaymentAdjustment, Claim, RateTable,
AfcarsSubmission, NcandsSubmission, QualityReview, DataQualityIssue,
AuditEvent, SecurityAlert, SecurityArchive, SecurityReview,
DetectionRule, NistControl, MajorChange, AdminUnit, WorkerIdentity,
Partner, PartnerApiKey, PartnerSignerKey,
RuleSet, RuleEvaluation,
}
#[derive(Clone, Debug)]
pub enum ListScope {
All,
AssignedWorker(Uuid), // SQL: WHERE assigned_worker_sub = $1
AssignedSupervisor(Uuid), // SQL: WHERE supervisor_sub = $1
Custom(Value), // for richer scopes the JDM ruleset returns
Denied,
}
#[async_trait]
pub trait AuthzEngine: Send + Sync {
async fn check(
&self,
claims: &Claims,
resource: ResourceRef<'_>,
action: Action,
) -> Result<(), ApiError>;
async fn auto_scope_list(
&self,
claims: &Claims,
resource_type: ResourceType,
action: Action,
jurisdiction: &str,
) -> Result<ListScope, ApiError>;
}
pub struct ZenAuthzEngine {
cache: Arc<tokio::sync::RwLock<RulesetCache>>,
rules_client: Arc<craig_rules_client::RulesClient>,
}
Cache module src/cache.rs:
use std::collections::HashMap;
use std::time::Instant;
use zen_engine::DecisionEngine;
use zen_engine::loader::MemoryLoader;
use zen_engine::model::DecisionContent;
pub struct RulesetCache {
by_name: HashMap<String, CachedRuleset>,
}
pub struct CachedRuleset {
pub name: String,
pub version: String,
pub fetched_at: Instant,
pub engine: DecisionEngine<MemoryLoader>,
}
Engine evaluation flow
For check(claims, resource, action):
-
Compute ruleset name:
format!("{}-authz-{}", resource.jurisdiction, resource.resource_type)(e.g.,"georgia-authz-case"). -
Lookup in cache. Miss → emit
authz.cache_missevent; returnApiError::Forbidden::with_detail("policy missing")(fail-closed). -
Build zen-engine input as
serde_json::Value:{ "claims": { "sub": "00000000-...", "preferred_username": "bob.smith", "roles": ["caseworker"] }, "resource": { "id": "...", "assigned_worker_sub": "...", "supervisor_sub": "...", "attrs": { "icwa_flag": false, "admin_unit": "Fulton" } }, "action": "read" } -
Run
engine.evaluate(input).await?. zen-engine returnsEvaluationResponse { result: Value, performance: … }. -
Parse
resultfor{ "allow": bool, "reason": Option<String> }. -
If
allow == false→ emitauthz.access_deniedevent with caller, resource, evaluated-policy-name, reason; returnApiError::Forbidden. -
If
allow == true→ Ok(()).
For auto_scope_list(claims, resource_type, action, jurisdiction):
-
Same ruleset lookup with
action = "list". -
JDM decision returns
{ "scope": "all" | "assigned_worker" | "assigned_supervisor" | "denied" | { "custom": {…} } }. -
Engine maps to
ListScopeenum. Handler translates to SQL filter.
D4. RMQ-driven cache invalidation
Publisher (in craig-rules' admin endpoints handling rule_set CRUD):
// services/craig-rules/src/api/sets.rs::update_rule_set
let mut tx = pool.begin().await?;
sqlx::query!("UPDATE rule_sets SET ... WHERE id = $1", id).execute(&mut *tx).await?;
let envelope = EventEnvelope::new(
"ruleset.changed",
json!({ "name": name, "version": new_version }),
);
publisher.publish_in_tx(&mut tx, &envelope, "ruleset.changed").await?;
tx.commit().await?;
Outbox worker (already shipped in platform-stab-2 §D2) drains and publishes to RabbitMQ.
Exchange definition (declared by craig-mq bootstrap):
-
Name:
craig.cache_invalidations -
Type:
topic -
Durable: yes
Consumer (in each consuming service’s bootstrap, e.g. services/craig-cases/src/main.rs):
let invalidation_queue = subscribe_exclusive(
&channel,
"craig.cache_invalidations",
"ruleset.changed.#",
).await?;
let cache_handle = authz_engine.cache_handle();
tokio::spawn(handle_cache_invalidations(invalidation_queue, cache_handle));
Failure modes:
-
Missed event (RabbitMQ blip) → TTL fallback (1h default) catches within window.
-
Bulk-fetch fails at boot → service bails fail-closed (analogous to encryption-mode-required boot guard).
-
RabbitMQ partition → existing cached rulesets continue serving; new policy changes don’t propagate until partition heals.
-
craig-rules outage during normal operation → cached rulesets continue serving.
TTL: Duration::from_secs(env::var("CRAIG_<SERVICE>__AUTHZ_POLICY_TTL_SECONDS").unwrap_or("3600").parse()?). Per-replica jitter 0-300s on the timer to avoid thundering herd at TTL boundary.
Bulk-fetch endpoint in craig-rules:
// GET /v1/rules/sets?prefix=georgia-authz-
pub async fn list_rule_sets(
Query(q): Query<ListRuleSetsQuery>,
) -> Result<Json<Vec<RuleSet>>, ApiError> {
if let Some(prefix) = q.prefix {
store::sets::list_by_prefix(&pool, &prefix).await
} else {
store::sets::list_all(&pool).await
}
}
D5. Default rulesets (JDM JSON files)
File layout:
rulesets/georgia/ georgia-authz-case.json georgia-authz-investigation.json georgia-authz-referral.json ... (per ResourceType) rulesets/texas/ texas-authz-case.json ...
Example rulesets/georgia/georgia-authz-case.json:
{
"metadata": {
"name": "georgia-authz-case",
"version": "1.0.0",
"description": "Curated CRAIG example. Replace before production deployment.",
"jurisdiction": "georgia",
"resource_type": "case"
},
"nodes": [
{
"id": "input",
"type": "inputNode",
"name": "Authz Request"
},
{
"id": "decision",
"type": "decisionTableNode",
"name": "Case authz decision",
"content": {
"hitPolicy": "first",
"inputs": [
{ "id": "in1", "name": "claims_roles", "field": "claims.roles" },
{ "id": "in2", "name": "is_assigned", "field": "$.resource.assigned_worker_sub == $.claims.sub" },
{ "id": "in3", "name": "action", "field": "action" }
],
"outputs": [
{ "id": "out1", "name": "allow", "field": "allow" },
{ "id": "out2", "name": "scope", "field": "scope" }
],
"rules": [
{ "in1": "contains 'admin'", "in2": "*", "in3": "*", "out1": "true", "out2": "\"all\"" },
{ "in1": "contains 'supervisor'", "in2": "*", "in3": "*", "out1": "true", "out2": "\"all\"" },
{ "in1": "contains 'caseworker'", "in2": "true", "in3": "*", "out1": "true", "out2": "\"assigned_worker\"" },
{ "in1": "contains 'caseworker'", "in2": "*", "in3": "'list'", "out1": "true", "out2": "\"assigned_worker\"" },
{ "in1": "contains 'readonly'", "in2": "*", "in3": "'read' or 'list'", "out1": "true", "out2": "\"all\"" },
{ "in1": "*", "in2": "*", "in3": "*", "out1": "false", "out2": "\"denied\"" }
]
}
},
{ "id": "output", "type": "outputNode", "name": "Authz Outcome" }
],
"edges": [
{ "id": "e1", "sourceId": "input", "targetId": "decision" },
{ "id": "e2", "sourceId": "decision", "targetId": "output" }
]
}
Texas fixture deliberately divergent in three dimensions:
-
Supervisor scope:
assigned_supervisor(case-scoped) instead ofall(realm-flat). -
Readonly scope:
assigned_worker(PTO-caseworker) instead ofall(auditor). -
Custom role: introduce
regional_supervisorwith ruleclaims.roles contains 'regional_supervisor' and resource.attrs.admin_unit in claims.attrs.assigned_units.
Both files prefaced Curated CRAIG example. Replace before production deployment. Acts as L3 regression fixture for the test plan.
Seeded via existing ruleset-seed mechanism (xtask seed-rulesets). New jurisdictions ship their own rulesets.
D6. Cross-service denormalization
11 tables across placement/financial/exchange need an assigned_worker_sub UUID column to enable local authz evaluation:
| Service | Tables |
|---|---|
craig-placement |
placements, education_records, health_records, kinship_options, home_documents |
craig-financial |
payments, payment_adjustments, claims |
craig-exchange |
icpc_requests, icpc_home_studies, icpc_attachments |
Migration shape per table:
-
ALTER TABLE <t> ADD COLUMN assigned_worker_sub UUID NULL -
CREATE INDEX idx_<t>_assigned_worker_sub ON <t> (assigned_worker_sub)(for LIST scope filter) -
xtask
cargo xtask backfill-cross-service-assignmentreads from cases service via existing reconciliation walker pattern (xtask/src/cmd/reconcile.rsprecedent), populates each row’sassigned_worker_subfrom the linkedcases.cases.assigned_worker. -
After backfill verified:
ALTER TABLE <t> ALTER COLUMN assigned_worker_sub SET NOT NULL(only for tables where parent case is non-NULL).
Replication shape:
-
cases service publishes
case.assignment_changed { case_id, old_sub, new_sub, changed_at }event whencases.assigned_workermutates (covers create + update). -
Each consuming service has inbox handler bound to
case.#topic with routing keycase.assignment_changed. Handler executes UPDATE across its local tables that reference the case_id. -
Failure semantics inherit from inbox three-state retry (platform-stab-2 §D3).
Eventually-consistent window (~5s typical) fails closed: stale reads see OLD sub, deny NEW worker until propagation completes.
D7. Handler integration pattern
GET-by-id pattern (see Step 8 for cases-service rollout):
pub async fn get_case(
Extension(claims): Extension<Claims>,
Extension(authz): Extension<Arc<dyn AuthzEngine>>,
Extension(jurisdiction): Extension<Jurisdiction>,
State(app): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<store::models::Case>, ApiError> {
let case = store::cases::get(app.db.inner(), id).await?
.ok_or_else(|| ApiError::not_found("case", id))?;
authz.check(
&claims,
ResourceRef {
resource_type: ResourceType::Case,
resource_id: case.id,
assigned_worker_sub: Some(case.assigned_worker),
supervisor_sub: case.supervisor,
jurisdiction: &jurisdiction.0,
attrs: hashmap! {
"icwa_flag" => case.icwa_flag.into(),
"admin_unit" => case.admin_unit.clone().into(),
"status" => case.status.clone().into(),
},
},
Action::Read,
).await?;
Ok(Json(case))
}
LIST pattern:
pub async fn list_cases(
Extension(claims): Extension<Claims>,
Extension(authz): Extension<Arc<dyn AuthzEngine>>,
Extension(jurisdiction): Extension<Jurisdiction>,
State(app): State<AppState>,
Query(query): Query<ListCasesQuery>,
) -> Result<Json<PageResponse<store::models::Case>>, ApiError> {
let scope = authz.auto_scope_list(
&claims,
ResourceType::Case,
Action::List,
&jurisdiction.0,
).await?;
let cases = store::cases::list_with_scope(app.db.inner(), scope, query).await?;
Ok(Json(cases))
}
Store fn list_with_scope:
pub async fn list_with_scope<'e, E: PgExecutor<'e>>(
executor: E,
scope: ListScope,
query: ListCasesQuery,
) -> Result<PageResponse<Case>> {
let mut sql = QueryBuilder::new("SELECT * FROM cases WHERE 1=1");
match scope {
ListScope::All => {},
ListScope::AssignedWorker(sub) => {
sql.push(" AND assigned_worker = ").push_bind(sub);
}
ListScope::AssignedSupervisor(sub) => {
sql.push(" AND supervisor = ").push_bind(sub);
}
ListScope::Denied => {
return Err(ApiError::forbidden("LIST denied by policy"));
}
ListScope::Custom(_) => {
return Err(ApiError::internal("custom scope not yet implemented"));
}
}
// ... existing query.search/sort/page filters, then execute
}
D8. HTTP verb to Action mapping
| HTTP | Path shape | Action |
|---|---|---|
GET |
|
List |
POST |
|
Create |
GET |
|
Read |
PUT |
|
Update |
PATCH |
|
Update |
DELETE |
|
Delete |
POST |
|
Approve |
POST |
|
<Workflow> (string-keyed) |
Sub-resources target their own type, not the parent:
-
GET /v1/cases/cases/{id}/household→ListonHouseholdMember(filtered by case_id) -
POST /v1/cases/contacts/{id}/attachments→CreateonContactAttachment
Multi-resource handlers call engine.check() per affected resource.
D9. Soft-delete behavior
Predicate runs first; soft-delete check is irrelevant to authz. GET-by-id returns the row to authorized callers regardless of active flag (audit-trail use case). LIST continues to filter active=true (separate from authz; semantically distinct).
Existence-leak via 403-vs-404 distinction is bounded: caller is already authenticated + role-checked before reaching the predicate.
D10. Test coverage (L1 + L2 + L3)
-
L1 (engine unit tests, ~50-100 tests): full coverage of
craig-authzcrate. JDM expression shapes, scope outcomes, fail-closed branches, cache-miss behavior, RMQ invalidation handler. Self-contained;cargo nextest run -p craig-authz. -
L2 (handler smoke tests, ~140 tests): one per handler verifying engine wiring. Generated via
#[authz_smoke_test(handler = "get_case", resource = "Case", action = "Read")]macro. Asserts that an unauthorized JWT gets 403 and an authorized JWT gets 200, using a minimal hand-crafted policy. -
L3 (fixture-driven regression tests, ~200 tests): per
(resource_type, jurisdiction), representative scenarios from default fixtures. Generated via proc macro readingrulesets/{jurisdiction}/*.json. Catches "CRAIG change broke a fixture’s behavior" regressions.
L1 covers engine correctness; L2 covers wiring; L3 covers contract regression. Total ~290-340 tests.
Out of scope for CRAIG: validating that any specific jurisdiction’s policies match their actual federal/state requirements. That’s the adopter’s responsibility.
D11. Jurisdiction readiness
D11.1 Hardcoded defaults removal
-
crates/craig-common/src/settings.rs:124default_jurisdiction()deleted. Field becomes required in deserializer (no#[serde(default = …)]). -
services/craig-intake/src/config.rs:270same. -
services/craig-web/src/config.rs:61, 65, 67default_theme(),default_branding_agency(),default_admin_unit_label()deleted.
Service bails at boot with a clear log message if any required env var is unset:
ERROR craig_cases::main: required env var CRAIG_CASES__JURISDICTION not set; refusing to start. See deployment-guide.adoc.
Devstack .env.example and docker-compose.yml updated to set all required vars explicitly. CI overrides updated.
D11.2 Admin-unit registry externalization
crates/craig-reference/src/fips.rs:1408-1414::admin_units_for_state() deprecated with #[deprecated] attribute. New API:
pub async fn admin_units_for_jurisdiction(
pool: &PgPool,
jurisdiction: &str,
) -> Result<Vec<AdminUnit>>;
Reads from admin_unit_registry (existing schema; populated via xtask).
GEORGIA_COUNTIES constant array (159 entries at crates/craig-reference/src/fips.rs:1226-1407) exported to default_admin_units/georgia.csv. New xtask:
cargo xtask seed-admin-units --jurisdiction georgia --file default_admin_units/georgia.csv
INSERTs ON CONFLICT DO NOTHING into admin_unit_registry. Same shape for texas.csv (closes #225).
Migration auto-seeds for known jurisdictions on first install.
D11.3 BFF/intake i18n hardcodes
-
services/craig-intake/static/report.html:82"County" label → FTL keyreport-form-admin-unit-label -
services/craig-intake/static/report.html:211"County is required." → FTL keyreport-error-admin-unit-required -
services/craig-web/templates/intake/report_detail.html:253actor_role dropdown — current static<option value="county_supervisor">…</option>→ replaced with template loop{% for role in screening_policy.actor_roles %}<option value="{{ role.id }}">{{ role.label }}</option>{% endfor %}wherescreening_policyis loaded at template render via the existing{jurisdiction}-screening-policyruleset metadata. -
services/craig-web/locales/en/web.ftl:82, 207, 471"Fulton" placeholders →e.g. <county>(jurisdiction-config-driven sample) -
services/craig-web/locales/en/public.ftl:63, 111state/county placeholders → generic
D11.4 Operational constants
-
ICPC 84-day deadline (
services/craig-exchange/src/api/icpc.rs:264) → moved to ruleset metadata in a new{jurisdiction}-icpc-policyruleset (mirrors screening-policy pattern). Default Georgia ruleset ships with{ "icpc_deadline_days": 84 }. -
Payment-period boundaries (
services/craig-financial/src/main.rs:148, 173) →craig-financial::settings::PaymentPeriodConfigenv-driven struct:period_start_day,period_boundary_kind: calendar_month | fiscal_month | iso_week. Defaultcalendar_month / day=1.
D12. ADRs
-
ADR-023: Multi-Jurisdictional Authorization Architecture — data-driven policy engine; jurisdiction first-class; no hardcoded policy in code.
-
ADR-024: Policy Engine on zen-engine + RMQ Cache Invalidation —
{jurisdiction}-authz-{resource}rulesets; in-memory cache withsubscribe_exclusiveinvalidation + TTL fallback; fail-closed on cache miss / boot-load failure. -
ADR-025: Identity Normalization — preferred_username TEXT → sub UUID; destructive migration justified by pre-1.0 status; first-production deployments use per-jurisdiction data-import tools.
-
ADR-026: IdP-Neutral Identity Layer — JWT/JWKS only; no IdP admin API client;
worker_identitiestable populated lazily from JWT claims; lookups served from CRAIG’s own table. -
ADR-027: Architectural Principles — pluggability everywhere, async-first, identity-neutral, configuration-as-data, don’t reinvent (OIDC, AMQP, JDM). Forward-references gitfs-jurisdiction-config follow-up plan.
D13. Service-to-service authorization
Some CRAIG calls cross service boundaries with a JWT minted by a service account, not by an end user — e.g., services/craig-intake/src/api/service_token.rs mints a service-account JWT to call craig-cases when partner-submitted reports are converted to cases (per services/craig-cases/src/api/reports.rs:95-98). The current pattern is the service-account JWT carries the caseworker role (or admin), and handlers' role-only check accepts it.
Under the policy engine, service-account JWTs need an explicit policy treatment so jurisdictions can choose whether internal-service calls bypass record-level authz (typical) or are scoped (defense-in-depth).
Recommended pattern:
-
Service-account JWTs carry a
service-accountclaim (a custom claim, not a CRAIG role) AND the operational role(s) needed (e.g.,caseworker). -
Default policies include explicit entries for the
service-accountrole:# Default service-account policy in georgia-authz-{resource}.json { "in1": "contains 'service-account'", "in2": "*", "in3": "*", "out1": "true", "out2": "\"all\"" } -
Documentation in
crates/craig-authz/README.mdand ADR-023 explicitly notes: "service-account claims are deployer-issued and must be deliberately granted; the default Georgia policy is permissive for internal-service flows but jurisdictions can scope tighter."
Existing service-account flow inventory (call sites that should grow service-account claim issuance):
-
services/craig-intake/src/api/service_token.rs(intake → cases for report conversion) -
services/craig-security/src/api/partners.rs:352(admin endpoint accepting service-account hits) -
Any future service-to-service call patterns
This is not a new IdP feature — service-account claims are a deployer-configurable claim mapping in Keycloak / Auth0 / etc. CRAIG’s policy engine treats service-account as a role-equivalent entry. Documented as part of jurisdiction-onboarding playbook (Step 13).
D14. Audit logging schema
The existing audit_log table (services/craig-security/migrations/20260305100000_create_security_tables.sql:2-14) is the destination for authz events. Schema unchanged; events use the existing details JSONB field for authz-specific structure.
Event types emitted by the engine:
action value |
details JSONB shape |
|---|---|
|
|
|
|
|
|
|
|
audit_log columns populated:
-
service: the service emitting (e.g.,craig-cases) -
user_id:claims.subfor user-driven events; service-account sub for service-driven events -
user_role: comma-joinedclaims.roles -
action: per the table above -
resource_type: matchesResourceType::to_string()(snake_case) -
resource_id: the affected row’s UUID, or NULL for cache events -
details: JSONB per shape above -
success:falseforaccess_denied,truefor cache events (informational)
Emission path: craig-authz does NOT write directly to the audit_log DB. Instead it publishes events on the existing outbox to craig.events with routing key authz.<event_type>. craig-security’s existing wildcard audit subscriber (per CLAUDE.md Phase 8: "wildcard audit subscriber") consumes them and writes to its own audit_log table. This preserves the established audit pattern; no new subscriber, no new table.
Verification of audit emission lives in L2 smoke tests: every "authorized fail-closed" scenario asserts an audit_log row appears with the matching shape.
D15. Bulk operation authz
Bulk endpoints (POST /v1/cases/batch-lookup at services/craig-cases/src/api/cases.rs:464, and any future bulk endpoints) need a per-row authz check with explicit "partial result" semantics. Without this, bulk endpoints become BOLA bypasses (caller submits N IDs and gets all N back regardless of authz).
Pattern:
pub async fn batch_lookup(
State(app): State<AppState>,
Extension(claims): Extension<Claims>,
Extension(authz): Extension<Arc<dyn AuthzEngine>>,
Extension(jurisdiction): Extension<Jurisdiction>,
Json(body): Json<BatchLookupRequest>,
) -> Result<Json<BatchLookupResponse>, ApiError> {
if body.case_ids.len() > MAX_BATCH_LOOKUP {
return Err(ApiError::bad_request(format!("maximum {MAX_BATCH_LOOKUP} IDs")));
}
let raw_cases = store::cases::batch_lookup_cases(app.db.inner(), &body.case_ids).await?;
let mut filtered = HashMap::new();
let mut unauthorized_count = 0;
for (id, case) in raw_cases {
match authz.check(
&claims,
ResourceRef {
resource_type: ResourceType::Case,
resource_id: id,
assigned_worker_sub: Some(case.assigned_worker),
supervisor_sub: case.supervisor,
jurisdiction: &jurisdiction.0,
attrs: HashMap::new(),
},
Action::Read,
).await {
Ok(()) => { filtered.insert(id, case); }
Err(ApiError::Forbidden { .. }) => { unauthorized_count += 1; }
Err(e) => return Err(e),
}
}
Ok(Json(BatchLookupResponse {
cases: filtered,
unauthorized_count, // NEW field
partial_auth: unauthorized_count > 0, // NEW field
}))
}
DTO change in crates/craig-cases-contracts/src/cases.rs::BatchLookupResponse:
pub struct BatchLookupResponse {
pub cases: HashMap<Uuid, CaseSummary>,
pub persons: HashMap<Uuid, PersonSummary>,
/// Number of requested IDs the caller was not authorized to see.
/// Audit log captures who the caller was; response just signals the count.
#[serde(default)]
pub unauthorized_count: usize,
/// True iff `unauthorized_count > 0` for either cases or persons.
#[serde(default)]
pub partial_auth: bool,
}
Performance: per-row engine.check() at most N=MAX_BATCH_LOOKUP (currently 100). At <100µs per check (zen-engine cached eval), total overhead <10ms. Acceptable.
Audit: each authz.access_denied event for a per-row deny is logged individually. Bulk endpoints can produce N audit events per request — bounded by MAX_BATCH_LOOKUP cap.
This pattern applies to: batch_lookup in cases (the only existing bulk endpoint); any future bulk operations follow the same shape.
D16. Custom claims source for JDM evaluation context
The default JDM rulesets use claims.roles, claims.sub, resource.assigned_worker_sub, resource.supervisor_sub, resource.attrs.<field>. Texas’s example fixture (D5) references claims.attrs.assigned_units — a custom claim. Where do those come from?
Source: worker_identities table extension. Add an attrs JSONB NOT NULL DEFAULT '{}' column to the schema in §D2:
ALTER TABLE worker_identities ADD COLUMN attrs JSONB NOT NULL DEFAULT '{}';
Population: at JWT-claim-upsert time (§D2 middleware hook), copy any non-standard JWT claims into attrs. CRAIG-recognized standard claims (sub, preferred_username, email, name, roles) populate the dedicated columns; everything else lands in attrs as a flat JSONB object.
Per-deployment claim mapping: deployers configure their IdP to emit the claims their policies reference. Examples:
-
Keycloak: realm role attributes + custom mapper that emits
attrs.assigned_unitsfrom the user’s group memberships -
Auth0: app metadata mapped to a custom claim
-
Okta: profile attributes mapped via OAuth claim mapper
Documentation: jurisdiction-onboarding playbook (Step 13) includes a "configuring custom claims for your IdP" section with examples for the major IdPs. CRAIG itself remains agnostic — it just reads what the JWT carries.
Engine evaluation: when building zen-engine input, craig-authz’s engine includes `claims.attrs from the cached worker_identities.attrs (looked up by claims.sub at request time). Single in-memory lookup; no per-request DB call after the upsert hook.
D17. CI authz coverage gate
To prevent regression where a future contributor adds a handler that bypasses engine.check(), ship a CI gate that walks every #[utoipa::path]-annotated handler function and asserts each calls into the engine.
New xtask command validate-authz-coverage (modeled on existing xtask/src/cmd/coverage_matrix.rs and xtask/src/cmd/check_docs.rs):
-
Walks
services//src/api/*/*.rsfiles -
Parses each via
syn(workspace dep already used by macros) -
For every function annotated with
#[utoipa::path(…)], checks the function body for at least one of:-
authz.check( -
authz.auto_scope_list( -
An allowlist comment immediately above the
#[utoipa::path]:// authz: skip — <reason>
-
-
Reports any handler that bypasses + reports any allowlist entry without a reason
Output (markdown report):
=== authz coverage report ===
Total handlers (utoipa::path): 214
Engine-wired: 207
Allowlisted: 7 (health, livez, readyz, openapi, etc.)
Bypassed (FAIL): 0
Allowlist legitimate exceptions:
-
/livez,/readyz,/healthz— k8s probes; pre-auth -
/openapi.json— public schema doc -
Any future public-by-design endpoint
Integration: append to cargo xtask validate (called from pre-push hook + CI). Failing the check (any "Bypassed" handler) bails the gate.
Spec lives in xtask/src/cmd/validate_authz_coverage.rs. Test fixture in xtask/tests/authz_coverage_test.rs exercises a handler-with-engine, handler-with-allowlist, handler-without-engine — asserts the third case fails the check.
D18. Missing-policy detection at boot
When a new ResourceType is added in CRAIG code post-Plan A, default policies might not cover it. The engine fail-closes correctly (cache miss → deny), but operators don’t know proactively.
At boot, after bulk-fetch loads cached rulesets, the engine enumerates the cartesian product ResourceType × Action and checks coverage:
async fn validate_default_policy_coverage(
cache: &RulesetCache,
jurisdiction: &str,
) -> Result<(), CoverageWarning> {
let mut missing = Vec::new();
for resource_type in ResourceType::iter() {
let ruleset_name = format!("{}-authz-{}", jurisdiction, resource_type);
if !cache.has(&ruleset_name) {
missing.push(ruleset_name);
}
}
if !missing.is_empty() {
tracing::warn!(
jurisdiction = jurisdiction,
missing_count = missing.len(),
"default policies missing for {} ResourceType(s); engine will fail-closed for affected requests. Add rulesets in `rulesets/{}/`",
missing.len(),
jurisdiction,
);
return Err(CoverageWarning::Missing(missing));
}
Ok(())
}
CoverageWarning::Missing is logged but does NOT bail boot — fail-closed at request time is the correct behavior (some (jurisdiction, ResourceType) combinations might be intentionally absent for that jurisdiction). Optionally promotable to bail via env var CRAIG_<SERVICE>__AUTHZ_REQUIRE_FULL_COVERAGE=true for strict deployments.
Per-Action granularity is delegated to the JDM ruleset itself (each ruleset handles all actions for its resource_type via the decision-table rows).
Steps
Step 1: Plan filing + 5 ADR drafts + GitLab issue tree
Files:
-
docs/modules/ROOT/pages/plans/multi-jurisdictional-authz.adoc— new (this content) -
docs/modules/ROOT/pages/adrs/adr-024-multi-jurisdictional-authz.adoc— new (Proposed) -
docs/modules/ROOT/pages/adrs/adr-025-policy-engine-design.adoc— new (Proposed) -
docs/modules/ROOT/pages/adrs/adr-026-identity-normalization.adoc— new (Proposed) -
docs/modules/ROOT/pages/adrs/adr-027-idp-neutral-identity.adoc— new (Proposed) -
docs/modules/ROOT/pages/adrs/adr-028-architectural-principles.adoc— new (Proposed) -
docs/modules/ROOT/nav.adoc— under* Plans / ** Active, add this plan; under ADRs, add 5 entries -
CHANGELOG.adoc— entry under== Unreleased
GitLab artifacts created:
-
1 epic at group level:
epic: multi-jurisdictional authorization (BOLA + jurisdiction-readiness) -
14 step-tracking issues, linked to the epic, labeled per character (
chorefor 1/14;featfor 2-13), milestone TBD
Branch: chore/multi-juris-authz-step1-plan-and-adrs
MR title: chore(plans): file multi-jurisdictional-authz plan + ADR-023..028 drafts + GitLab issue tree [Step 1]
Verification:
-
cargo xtask check-docs— Tier 1 docs untouched; new ADR + plan AsciiDoc-correct -
cargo xtask validate --skip-docker— pre-push gate green (no code changes) -
After push:
glab mr view <id>shows pipeline pass;glab issue list --milestone "…"shows new epic + 14 issues
CHANGELOG draft:
=== Multi-Jurisdictional Authorization plan + ADR-023..028 drafts [Step 1] (DATE) Plan filed: docs/modules/ROOT/pages/plans/multi-jurisdictional-authz.adoc. 5 ADRs drafted (Proposed status): ADR-023 multi-jurisdictional authz architecture, ADR-024 zen-engine policy engine + RMQ cache invalidation, ADR-025 identity normalization, ADR-026 IdP-neutral identity layer, ADR-027 architectural principles. Epic + 14 step issues filed at GitLab. No code changes; doc-only MR.
Step 2: ADR-025 acceptance + identity normalization
Files:
-
docs/modules/ROOT/pages/adrs/adr-026-identity-normalization.adoc— flipProposed→Accepted -
services/craig-cases/migrations/<TS>_assigned_worker_uuid.sql— new (destructive ALTER per §D1) -
services/craig-cases/src/store/models.rs— changeassigned_worker: String→assigned_worker: Uuid; same forsupervisor,investigations.assigned_worker -
services/craig-cases/src/store/cases.rs— update INSERT/UPDATE bindings to UUID -
services/craig-cases/src/store/investigations.rs— same -
services/craig-cases/src/api/cases.rs— handler writesclaims.sub_uuid()intoassigned_worker;request body’s assigned_worker_subfield if present -
services/craig-cases/src/api/investigations.rs— same pattern -
crates/craig-cases-contracts/src/cases.rs— DTOs changed:CreateCaseRequest.assigned_worker: Uuid,supervisor: Option<Uuid>(wasString) -
crates/craig-cases-contracts/src/investigations.rs—CreateInvestigationRequest.assigned_worker: Uuid -
All cases-service integration tests in
services/craig-cases/tests/api/referencing"bob.smith"etc. asassigned_workerstrings — updated to use the seeded UUID00000000-0000-0000-0000-000000000002. Use the existingcraig_test_lib::SeedSubsconstants if available; otherwise add them. -
tools/craig-seed/src/datagen.rs—assigned_workervalues become UUIDs from a seeded user pool -
services/craig-cli/tests/cli/referral.rs:22and otherassigned_worker: "bob.smith".into()test sites — convert to UUID
Branch: feat/multi-juris-authz-step2-identity-normalization
MR title: feat(craig-cases, craig-cases-contracts): identity normalization — assigned_worker TEXT → UUID [Step 2 of multi-juris-authz]
Verification:
-
cargo xtask dev reseed— devstack regenerates with new shape -
cargo nextest run -p craig-cases --tests— all integration tests pass with UUID-typed identity -
cargo nextest run --workspace— workspace-wide tests pass (test-lib + cli updates included) -
cargo xtask validate --skip-docker— pre-push green
CHANGELOG draft:
=== Identity normalization (Step 2 of multi-juris-authz) (DATE)
ADR-025 accepted. cases.cases.{assigned_worker, supervisor} and
cases.investigations.assigned_worker columns migrated from TEXT
(preferred_username) to UUID (claims.sub). Destructive migration
justified by pre-1.0 status + no production data; first-production
deployments use per-jurisdiction data-import tools, not this migration.
Devstack data regenerated via reseed. Test fixtures updated to use seeded
sub UUIDs instead of display-name strings. Workspace tests: 1696/1696.
Step 3: Worker identities table + IdP-neutral autocomplete
Files:
-
services/craig-security/migrations/<TS>_worker_identities.sql— new table per §D2 (includesattrs JSONB NOT NULL DEFAULT '{}'column per §D16) + pg_trgm extension enable -
services/craig-security/src/store/worker_identities.rs— new module (upsert + search fns; upsert mergesattrsfrom JWT custom claims) -
services/craig-security/src/store/models.rs— addWorkerIdentitystruct (withattrs: Valuefield) -
crates/craig-auth/src/middleware.rs::JwtMiddleware::on_request— callupsert_worker_identityafter Claims validation; pass non-standard JWT claims into theattrsJSONB -
crates/craig-auth/src/middleware.rs— adddb: Arc<PgPool>field on JwtMiddleware (security-service connection only; other services use a scoped helper that no-ops if db absent) -
crates/craig-auth/src/claims.rs— extendClaimsdeserializer to captureextra: HashMap<String, Value>for non-standard claims via#[serde(flatten)] -
services/craig-security/src/api/workers.rs— new module withGET /v1/security/workers?search=&limit=; rate-limited to 60 req/min/sub via existing rate-limit middleware; minimum 2-character search query -
services/craig-security/src/api/mod.rs— register route -
crates/craig-cases-contracts/src/workers.rs— new (DTOs:WorkerIdentitySummary,ListWorkersQuery) -
services/craig-security/tests/api/workers.rs— new (8 tests: lazy upsert via JWT, search by username, search by display name, search with diacritics, search below minimum length returns 400, prefix collision returns multiple, rate-limit triggers 429, custom claim populates attrs column) -
.claude/docs/services.md— document new endpoint + table + attrs column
Branch: feat/multi-juris-authz-step3-worker-identities
MR title: feat(craig-security, craig-auth): worker_identities table + lazy JWT-claim population + autocomplete endpoint [Step 3 of multi-juris-authz]
Verification:
-
cargo xtask dev reseed -
cargo nextest run -p craig-security --test api workers— 3 new tests pass -
Manual: log in as jane.doe via
craig auth login(CLI); verifyworker_identitiesrow appears with sub + preferred_username -
cargo xtask validate --skip-docker— pre-push green
CHANGELOG draft:
=== Worker identities table + IdP-neutral autocomplete (Step 3 of multi-juris-authz) (DATE) ADR-026 implementation: new worker_identities table in craig-security populated lazily from JWT claims by craig-auth's middleware. Includes attrs JSONB column for custom claims (per-deployment IdP mapping; e.g., unit-membership, region, etc.) used by JDM policy expressions in later steps. No IdP admin API call ever; identity backend remains pluggable across Keycloak/Auth0/Okta/AD/etc. via OIDC + JWKS only. New endpoint GET /v1/security/workers?search= for BFF reassignment autocomplete. Trigram indexes for fuzzy match. Rate-limited (60/min) and minimum 2-char query length to bound enumeration. Workspace tests: 1696 → 1704 (+8).
Step 4: ADR-023/024/026/027 acceptance + craig-authz crate skeleton
Files:
-
docs/modules/ROOT/pages/adrs/adr-{023,024,026,027}.adoc— flip Proposed → Accepted (ADR-025 was already Accepted in Step 2) -
crates/craig-authz/Cargo.toml— new (deps: zen-engine workspace, craig-auth, craig-common, craig-rules-client, async-trait, anyhow, serde, serde_json, tokio, tracing, strum, uuid, syn for the coverage-gate impl) -
crates/craig-authz/src/lib.rs— public surface per §D3 (trait, types, re-exports) -
crates/craig-authz/src/engine.rs—ZenAuthzEngineimpl +validate_default_policy_coverageper §D18 -
crates/craig-authz/src/cache.rs—RulesetCacheper §D3 -
crates/craig-authz/src/types.rs—ResourceRef,Action,ResourceType,ListScope,CoverageWarning -
crates/craig-authz/src/error.rs— Authz-specific error mapping to ApiError -
crates/craig-authz/src/audit.rs—emit_audit_eventhelper publishingauthz.access_denied/authz.cache_miss/authz.cache_refreshedenvelopes per §D14 schema -
crates/craig-authz/tests/engine_unit.rs— L1 unit tests (~50-100 tests covering grammar shapes, scopes, fail-closed, cache miss, audit emission, missing-policy detection) -
crates/craig-authz/tests/fixtures/— minimal hand-crafted JDM ruleset fixtures for unit tests -
xtask/src/cmd/validate_authz_coverage.rs— new xtask per §D17 (AST walker viasyn; allowlist comments; markdown report) -
xtask/src/cmd/mod.rs— addpub mod validate_authz_coverage; -
xtask/src/cmd/validate.rs— call intovalidate_authz_coverage::runfrom existing validate command -
xtask/tests/authz_coverage_test.rs— fixture cases (with-engine, with-allowlist, bypassed) -
crates/craig-common/src/settings.rs— addauthz_require_full_coverage: bool(default false) per §D18 -
Cargo.toml(workspace) — addcraig-authzto members -
.claude/docs/shared-crates.md— document craig-authz public API
Branch: feat/multi-juris-authz-step4-craig-authz-crate
MR title: feat(craig-authz): policy engine skeleton + CI authz-coverage gate + missing-policy detection [Step 4 of multi-juris-authz]
Verification:
-
cargo build -p craig-authz— clean build -
cargo nextest run -p craig-authz— all L1 tests pass (~80 covering grammar/scope/fail-closed/audit-emission/missing-policy detection) -
cargo clippy -p craig-authz --all-targets --all-features — -D warnings— clean -
cargo xtask validate-authz-coverage— runs against current handlers; expect ~140 handlers reported, all marked "no engine wired yet (will be addressed in Steps 8-11)" — gate is non-failing on this MR but baseline established -
cargo xtask validate --skip-docker— pre-push green
CHANGELOG draft:
=== craig-authz crate skeleton (Step 4 of multi-juris-authz) (DATE) ADRs 023/024/026/027 accepted. New crates/craig-authz crate ships AuthzEngine trait, ZenAuthzEngine implementation wrapping zen-engine, ResourceRef/Action/ResourceType/ListScope types, and ~80 L1 unit tests covering JDM grammar shapes, scope outcomes, fail-closed branches, and cache-miss behavior. No service integration yet — Steps 8-11 wire handlers. Workspace tests: 1699 → ~1779 (+~80).
Step 5: RMQ cache invalidation + bulk-fetch endpoint
Files:
-
crates/craig-mq/src/lib.rs— defineCACHE_INVALIDATIONS_EXCHANGE: &str = "craig.cache_invalidations"; declare in bootstrap -
crates/craig-mq/src/subscriber.rs—subscribe_exclusivealready exists; verify or extend for cache-invalidation routing -
services/craig-rules/src/api/sets.rs— admin endpoints (POST/PUT/DELETE) publishruleset.changed { name, version }viapublish_in_tx; ALSO publishauthz.policy.changedper §D14 schema (when the changed ruleset name matches-authz-) -
services/craig-rules/src/api/sets.rs— newGET /v1/rules/sets?prefix=&limit=endpoint (bulk-fetch) -
crates/craig-cases-contracts/src/rules_sets.rs(or matching DTO crate) —ListRuleSetsQuery { prefix: Option<String>, limit: Option<u32> } -
crates/craig-authz/src/cache.rs— wire RMQ inbox handler that invalidates entries onruleset.changed.<name>; emitsauthz.cache_refreshedevent per §D14; TTL refresh task with jitter -
crates/craig-rules-client/src/lib.rs(new or extended) —list_by_prefix+get_by_nameHTTP clients used byZenAuthzEngineboot-load -
crates/craig-authz/tests/cache_invalidation.rs— integration test: spawn in-memory mock craig-rules, push aruleset.changedevent, verify cache entry invalidated andauthz.cache_refreshedevent emitted -
crates/craig-authz/tests/audit_emission.rs— integration test: trigger access_denied + cache_miss paths; verify audit_log row appears in craig-security’s table with correct envelope shape per §D14 -
crates/craig-common/src/settings.rs— addauthz_policy_ttl_seconds: u64field with default 3600 -
All 6 consuming services'
src/main.rs— register cache-invalidation subscriber viasubscribe_exclusive
Branch: feat/multi-juris-authz-step5-cache-invalidation
MR title: feat(craig-mq, craig-rules, craig-authz): RMQ cache invalidation + ruleset bulk-fetch endpoint [Step 5 of multi-juris-authz]
Verification:
-
cargo xtask dev reseed -
cargo nextest run -p craig-authz— cache-invalidation tests pass -
cargo nextest run -p craig-rules --test api— bulk-fetch endpoint test passes -
Manual: edit a ruleset via craig-rules admin endpoint; verify all replicas of cases service refresh their cache within 1s (check
authz.cache_refreshedlog line) -
cargo xtask validate --skip-docker— pre-push green
CHANGELOG draft:
=== RMQ-driven authz cache invalidation (Step 5 of multi-juris-authz) (DATE) craig-rules publishes ruleset.changed events on its outbox when admin endpoints mutate rule sets. New craig.cache_invalidations topic exchange fan-outs to all consuming-service replicas via subscribe_exclusive. Local in-memory caches refresh on event; TTL fallback (1h default, env-overridable) catches missed events. New bulk-fetch endpoint GET /v1/rules/sets?prefix= for boot-time cache loading. Fail-closed boot guard if bulk-fetch fails. Workspace tests: ~1779 → ~1789 (+~10).
Step 6: Default authz rulesets (Georgia + Texas)
Files:
-
rulesets/georgia/georgia-authz-case.json— new (per §D5 example) -
rulesets/georgia/georgia-authz-{investigation,referral,report,placement,foster_home,payment,…}.json— ~30 files for the resource-type set -
rulesets/texas/texas-authz-*.json— same set, deliberately divergent per §D5 -
rulesets/georgia/georgia-icpc-policy.json— new (carriesicpc_deadline_days: 84) -
rulesets/texas/texas-icpc-policy.json— placeholder (Texas ICPC config TBD by Texas team; carry Georgia’s 84 as starter) -
tools/craig-seed/src/cmd/seed_rulesets.rs(or equivalent existing path) — extend to seed the new authz rulesets if not already covered by the wildcard pattern -
rulesets/README.md— document naming convention{jurisdiction}-authz-{resource}+ the "curated example, replace before production" caveat -
services/craig-rules/tests/api/authz_rulesets.rs— verify the seeded rulesets exist aftercargo xtask dev reseed
Branch: feat/multi-juris-authz-step6-default-rulesets
MR title: feat(rulesets): default authz rulesets for Georgia + Texas [Step 6 of multi-juris-authz]
Verification:
-
cargo xtask dev reseed -
glab api 'projects/…/v1/rules/sets?prefix=georgia-authz-'— returns ~30 rulesets -
cargo nextest run -p craig-rules --test api authz_rulesets— passes -
cargo xtask validate --skip-docker— pre-push green
CHANGELOG draft:
=== Default authz rulesets — Georgia + Texas (Step 6 of multi-juris-authz) (DATE)
~60 JDM authz rulesets shipped under rulesets/{georgia,texas}/. Georgia
fixture: realm-flat supervisor, readonly = auditor. Texas fixture
deliberately divergent: case-scoped supervisor, readonly = PTO-caseworker,
custom regional_supervisor role with admin_unit-scoped scope. Both
prefaced "Curated CRAIG example. Replace before production deployment."
Acts as L3 regression fixture for upcoming engine-adoption steps.
Step 7: Cross-service denormalization
Files:
-
services/craig-placement/migrations/<TS>_assigned_worker_sub.sql— ADD column on placements/education_records/health_records/kinship_options/home_documents -
services/craig-financial/migrations/<TS>_assigned_worker_sub.sql— ADD column on payments/payment_adjustments/claims -
services/craig-exchange/migrations/<TS>_assigned_worker_sub.sql— ADD column on icpc_requests/icpc_home_studies/icpc_attachments -
services/craig-{placement,financial,exchange}/src/store/models.rs— addassigned_worker_sub: Option<Uuid>field -
xtask/src/cmd/backfill_cross_service_assignment.rs— new xtask followingxtask/src/cmd/reconcile.rsprecedent -
services/craig-cases/src/events.rs— addpublish_assignment_changedfn, called from create_case/update_case + create_investigation/update_investigation handlers (inside the transaction viapublish_in_tx) -
services/craig-{placement,financial,exchange}/src/main.rs— register inbox handler bound tocase.assignment_changed(subscribe, not subscribe_exclusive — competing-consumer for at-least-once update semantics) -
services/craig-{placement,financial,exchange}/src/inbox/case_assignment.rs— handler that UPDATEs all referencing rows -
Per-service integration tests in
services/craig-{placement,financial,exchange}/tests/api/cross_service_assignment.rs— verify denormalized column updates on event
Branch: feat/multi-juris-authz-step7-cross-service-denorm
MR title: feat(craig-placement, craig-financial, craig-exchange): denormalize assigned_worker_sub via case.assignment_changed events [Step 7 of multi-juris-authz]
Deploy ordering (within this MR, applied in sequence by the standard rolling deploy):
-
ALTER ADD column migrations on all 11 tables (additive; safe to run before consumers exist).
-
xtask backfill from cases service (idempotent; can run before or after consumers — backfill reads cases via reconciliation walker, no event dependency).
-
Deploy consuming services (craig-placement, craig-financial, craig-exchange) first with inbox handlers registered. Inbox queues bind to
case.#topic with routing keycase.assignment_changed; messages start queueing if any are published, but won’t deadletter — at-least-once semantics + idempotent UPDATE statements absorb duplicates. -
Deploy craig-cases LAST with the new event publisher. From this point, every assigned_worker mutation publishes an event that consumers process within outbox-poll-cadence (~5s).
-
Backfill is idempotent against any drift from events that fired during the deploy window.
If consumers are deployed AFTER the cases publisher, no data is lost (RabbitMQ holds events in queue), but the propagation is delayed — backfill reconciles. The ordering above is the minimum-lag shape.
Rollback: if Step 7 needs revert, reverse-deploy: take cases publisher down first; let consumer queues drain; revert consumers; revert migrations (DROP column). Backfill xtask is re-runnable.
Verification:
-
cargo xtask dev reseed -
cargo xtask backfill-cross-service-assignment— populates new columns from cases service -
cargo nextest run --workspace— cross-service assignment tests pass -
Manual: change a case’s assigned_worker via PUT /v1/cases/cases/{id}; verify referenced placements/payments/icpc_requests update their
assigned_worker_subwithin ~5s (poll DB; check inbox-handler log lines forcase.assignment_changed processed for case_id=…) -
cargo xtask validate --skip-docker— pre-push green
CHANGELOG draft:
=== Cross-service denormalization for record-level authz (Step 7 of multi-juris-authz) (DATE) 11 tables across placement/financial/exchange gain assigned_worker_sub UUID column for local authz evaluation without cross-service joins. xtask backfill from cases service. cases service publishes case.assignment_changed events on outbox; consuming services have inbox handlers updating rows. Eventually-consistent window (~5s typical) fails closed in conservative direction. Workspace tests: ~1789 → ~1799 (+~10).
Step 8: Cases service engine adoption
Files:
-
services/craig-cases/src/api/{cases,investigations,referrals,reports,contacts,court_orders,case_plans,case_plan_tasks,persons,report_persons,report_attachments,contact_attachments,screening_decisions,disposition_follow_ups}.rs— replace role checks withauthz.check(…)(single-row) orauthz.auto_scope_list(…)(LIST). ~57 sites. -
services/craig-cases/src/api/cases.rs::batch_lookup— adopt bulk-operation pattern per §D15: per-rowengine.check()with drop-on-deny;BatchLookupResponsegainsunauthorized_count+partial_authfields -
crates/craig-cases-contracts/src/cases.rs::BatchLookupResponse— DTO change per §D15 -
services/craig-cases/src/main.rs— registercraig_authz::ZenAuthzEnginein axum extensions -
services/craig-cases/src/store/cases.rs— newlist_with_scopefn per §D7 -
Same pattern for
investigations.rs,referrals.rs, etc. — ~14 store fns gain_with_scopevariants -
services/craig-cases/tests/api/authz_smoke.rs— generated tests via#[authz_smoke_test]macro (L2) -
services/craig-cases/tests/api/authz_fixture_regression.rs— generated tests via macro readingrulesets/{georgia,texas}/georgia-authz-*.json(L3) -
services/craig-cases/tests/api/batch_lookup_authz.rs— new integration test: POST batch with mixed authorized/unauthorized IDs; assertpartial_auth: true+unauthorized_count > 0+ filtered cases dict -
crates/craig-authz/src/test_macros.rs— proc macros for L2/L3 generation -
.claude/docs/services.md— document new authz integration on cases (incl. bulk-lookup behavior)
Branch: feat/multi-juris-authz-step8-cases-adoption
MR title: feat(craig-cases): wire AuthzEngine into ~57 handlers + L2/L3 test coverage [Step 8 of multi-juris-authz]
Verification:
-
cargo xtask dev reseed -
cargo nextest run -p craig-cases --tests— all existing + new tests pass -
Manual: log in as bob.smith (caseworker, sub
00000000-…-000000000002); GET /v1/cases/cases/{id} where id is a case assigned to jane.doe — expect 403. GET on a case assigned to bob — expect 200. -
Manual: log in as admin; GET any case — expect 200.
-
cargo xtask validate --skip-docker— pre-push green
CHANGELOG draft:
=== Cases service authz adoption (Step 8 of multi-juris-authz) (DATE) ~57 handlers in craig-cases now use authz.check() / auto_scope_list() instead of role-only checks. Record-level authorization enforced for GET-by-id (predicate) + LIST (auto-scope SQL filter). Soft-delete: predicate first; authorized callers see deleted rows. ~80 new tests across L2 (handler smoke, generated via macro) + L3 (fixture-driven regression for Georgia + Texas). Workspace tests: ~1799 → ~1879 (+~80).
Step 9: Placement + exchange engine adoption
Files:
-
services/craig-placement/src/api/*.rs— ~20 handler sites -
services/craig-exchange/src/api/*.rs— ~22 handler sites (incl. ICPC) -
services/craig-{placement,exchange}/src/main.rs— register engine -
services/craig-{placement,exchange}/src/store/*.rs—_with_scopestore fns -
services/craig-{placement,exchange}/tests/api/authz_{smoke,fixture}.rs— generated tests
Branch: feat/multi-juris-authz-step9-placement-exchange-adoption
MR title: feat(craig-placement, craig-exchange): wire AuthzEngine into ~42 handlers [Step 9 of multi-juris-authz]
Verification:
-
cargo xtask dev reseed -
cargo nextest run -p craig-placement -p craig-exchange --tests -
cargo xtask validate --skip-docker
CHANGELOG draft:
=== Placement + exchange authz adoption (Step 9 of multi-juris-authz) (DATE) ~42 handlers in craig-placement + craig-exchange now use the AuthzEngine. ICPC handlers (8) use the icpc_coordinator role; transactions (4) use supervisor; agreements/partners (12) admin-only — all routed through the engine for jurisdiction-aware policy lookup. Workspace tests: ~1879 → ~1939 (+~60).
Step 10: Financial + reporting engine adoption
Files:
-
services/craig-financial/src/api/*.rs— ~13 handler sites -
services/craig-reporting/src/api/*.rs— ~15 handler sites -
services/craig-{financial,reporting}/src/main.rs— register engine -
services/craig-{financial,reporting}/src/store/*.rs—_with_scopestore fns -
services/craig-{financial,reporting}/tests/api/authz_{smoke,fixture}.rs— generated tests
Branch: feat/multi-juris-authz-step10-financial-reporting-adoption
MR title: feat(craig-financial, craig-reporting): wire AuthzEngine into ~28 handlers [Step 10 of multi-juris-authz]
Verification:
-
cargo xtask dev reseed -
cargo nextest run -p craig-financial -p craig-reporting --tests -
cargo xtask validate --skip-docker
CHANGELOG draft:
=== Financial + reporting authz adoption (Step 10 of multi-juris-authz) (DATE) ~28 handlers in craig-financial + craig-reporting now use the AuthzEngine. Payment approval + adjustment approval workflows route through the engine's Approve action. AFCARS/NCANDS submissions scoped per jurisdiction. Workspace tests: ~1939 → ~1979 (+~40).
Step 11: Security + rules engine adoption
Files:
-
services/craig-security/src/api/*.rs— ~10 handler sites (most admin-only) -
services/craig-rules/src/api/*.rs— ~2 handler sites -
services/craig-{security,rules}/src/main.rs— register engine -
services/craig-{security,rules}/tests/api/authz_{smoke,fixture}.rs— generated tests -
xtask/src/cmd/validate_authz_coverage.rs— flip from soft-warn to hard-fail mode (handlers that lack engine wiring outside the allowlist now fail the gate) -
xtask/src/cmd/validate.rs— makevalidate-authz-coveragea required step (no longer skippable)
Branch: feat/multi-juris-authz-step11-security-rules-adoption
MR title: feat(craig-security, craig-rules): wire AuthzEngine into ~12 handlers + flip authz-coverage gate to hard-fail [Step 11 of multi-juris-authz]
Verification:
-
cargo xtask dev reseed -
cargo nextest run -p craig-security -p craig-rules --tests -
cargo xtask validate-authz-coverage— now hard-fails if any handler lacks engine wiring; expect zero failures across all 7 stateful services -
cargo xtask validate --skip-docker— pre-push green (now includes hard-fail gate)
CHANGELOG draft:
=== Security + rules authz adoption (Step 11 of multi-juris-authz) (DATE) ~12 handlers in craig-security + craig-rules now use the AuthzEngine. Most security handlers are admin-only and route via a uniform admin policy. Closes the BOLA/IDOR surface across all 7 stateful services (~140 sites total across Steps 8-11). Workspace tests: ~1979 → ~1999 (+~20).
Step 12: Jurisdiction-readiness Phase A — defaults removal
Files:
-
crates/craig-common/src/settings.rs:124— deletedefault_jurisdiction(); field becomes required -
services/craig-intake/src/config.rs:270— same -
services/craig-web/src/config.rs:61, 65, 67— deletedefault_theme(),default_branding_agency(),default_admin_unit_label() -
services/craig-{cases,placement,exchange,financial,reporting,security,rules}/src/main.rs— boot-guard messages clearer if env vars missing -
docker-compose.yml— set all required env vars explicitly -
.env.example— same -
docs/modules/ROOT/pages/deployment-guide.adoc— document required env vars per service -
.gitlab-ci.yml— CI overrides updated
Branch: feat/multi-juris-authz-step12-defaults-removal
MR title: feat(craig-common, craig-intake, craig-web): remove Georgia hardcoded defaults; required env vars [Step 12 of multi-juris-authz]
Verification:
-
Manual: unset
CRAIG_CASES__JURISDICTIONin a test container; verify boot fails with clear message -
cargo xtask dev reseed— devstack still works (env vars set) -
cargo nextest run --workspace -
cargo xtask validate --skip-docker
CHANGELOG draft:
=== Jurisdiction-readiness Phase A: defaults removal (Step 12 of multi-juris-authz) (DATE) BREAKING (devstack only): default_jurisdiction(), default_theme(), default_branding_agency(), default_admin_unit_label() removed. Each becomes a required env var; service bails at boot if unset. Devstack .env.example and docker-compose.yml updated; production deployments must set all required vars explicitly. Removes Georgia as a silent fallback for non-Georgia adopters.
Step 13: Jurisdiction-readiness Phase B — admin units + i18n + operational constants
Files:
-
crates/craig-reference/src/fips.rs— deprecateadmin_units_for_state; replace withadmin_units_for_jurisdiction(pool, jurisdiction)reading from DB -
default_admin_units/georgia.csv— new (159 rows extracted fromGEORGIA_COUNTIES) -
default_admin_units/texas.csv— new (Texas counties; closes #225) -
xtask/src/cmd/seed_admin_units.rs— new -
services/craig-security/migrations/<TS>_seed_default_admin_units.sql— auto-seed for known jurisdictions on first install -
services/craig-intake/static/report.html:82—i18n key="report-form-admin-unit-label" -
services/craig-intake/static/report.html:211—i18n key="report-error-admin-unit-required" -
services/craig-intake/locales/en/public.ftl— add new keys -
services/craig-web/templates/intake/report_detail.html:253— replace static dropdown with template loop fetching from screening-policy ruleset metadata -
services/craig-web/locales/en/web.ftl:82, 207, 471— "Fulton" placeholders → generic -
services/craig-web/locales/en/public.ftl:63, 111— generic -
services/craig-exchange/src/api/icpc.rs:264— readicpc_deadline_daysfrom{jurisdiction}-icpc-policyruleset metadata -
rulesets/{georgia,texas}/{j}-icpc-policy.json— already shipped in Step 6; verify metadata field present -
services/craig-financial/src/main.rs:148, 173— replace calendar-month logic withPaymentPeriodConfigfrom settings -
crates/craig-common/src/settings.rs— addpayment_period: PaymentPeriodConfigstruct -
services/craig-web/src/routes/cases/list.rsand similar — stop sending?worker=query param to backend (engine determines scope)
Branch: feat/multi-juris-authz-step13-admin-units-and-i18n
MR title: feat(craig-reference, craig-web, craig-intake, craig-exchange, craig-financial): externalize Georgia counties + BFF i18n hardcodes + operational constants [Step 13 of multi-juris-authz]
Verification:
-
cargo xtask dev reseed— Georgia + Texas counties seeded -
cargo nextest run --workspace -
Manual: visit
/intake/reportin BFF; verify "County" label is FTL-keyed; verify actor_role dropdown reflects screening-policy metadata -
cargo xtask e2e— full E2E suite passes -
cargo xtask validate --skip-docker
CHANGELOG draft:
=== Jurisdiction-readiness Phase B: admin units + i18n + operational constants (Step 13 of multi-juris-authz) (DATE) GEORGIA_COUNTIES constant (159 rows) externalized from craig-reference to admin_unit_registry table. New xtask seed-admin-units. Texas counties seeded (closes #225). BFF/intake i18n hardcodes (5 P0 items from 2026-05-07 audit) extracted to FTL keys; actor_role dropdown fetches from screening-policy ruleset metadata at render time. ICPC 84-day deadline → ruleset metadata. Payment-period boundaries → jurisdiction-config-driven (calendar/fiscal/iso-week). BFF stops sending ?worker= query param (engine determines scope post-Step 8).
Step 14: Plan completion audit + archive
Files:
-
docs/modules/ROOT/pages/plans/multi-jurisdictional-authz.adoc— Status table → all Complete -
docs/modules/ROOT/nav.adoc— Active → next plan or "(none)" -
docs/modules/ROOT/pages/plans/archive.adoc— new row under Security & Compliance with all step MR numbers -
.claude/CLAUDE.md— Phase Status final stats; new row "Multi-Jurisdictional Authorization" -
CHANGELOG.adoc— wrap-up entry
Branch: chore/multi-juris-authz-step14-archive
MR title: chore: Multi-Jurisdictional Authorization plan completion + archive [Step 14 of multi-juris-authz]
Verification:
-
Spawn
plan-completion-auditsubagent per delivery-protocol.md -
Verify all 13 prior steps complete via MR list
-
glab issue list --milestone "…"shows zero multi-juris-authz issues open -
glab epic view <N>state == closed -
cargo xtask check-docs— Tier 1 docs clean
CHANGELOG draft:
=== Multi-Jurisdictional Authorization plan completion + archive [Step 14 of multi-juris-authz] (DATE) Plan completion. ~140 handler sites now enforce record-level authz via zen-engine policy DSL; 11 cross-service tables denormalized; identity model normalized to claims.sub UUID; IdP-neutral worker_identities table populated lazily from JWT claims; Georgia hardcoded defaults removed; admin units externalized; BFF/intake i18n hardcoded items fixed. CRAIG is now genuinely multi-jurisdictional: states/tribes/ counties onboard via rulesets + CSVs + env vars, no code changes. Plan B (pii-and-partner-edge-hardening) safe to execute. Closes epic.
Files Touched (aggregate)
| File | Change |
|---|---|
|
Step 1 — new plan file |
|
Step 1 — 5 new ADRs |
|
Step 2 — destructive ALTER |
|
Step 2 — handler/store updates |
|
Step 2 — DTO type changes |
|
Step 3 — new table |
|
Step 3 — JWT-claim upsert hook |
|
Step 3 — autocomplete endpoint |
|
Step 4 — new crate (engine, cache, audit emission, missing-policy detection) |
|
Step 4 — new CI gate (soft-warn); Step 11 — flip to hard-fail |
|
Step 5 — cache_invalidations exchange |
|
Step 5 — bulk-fetch endpoint + outbox publishing |
|
Step 6 — ~60 default rulesets |
|
Step 7 — denormalize column |
|
Step 7 — assignment_changed publisher |
Per-service |
Steps 8-11 — engine wiring at ~140 sites |
|
Step 12 — defaults removal |
|
Step 13 — admin-unit externalization |
|
Step 13 — i18n hardcodes |
|
Step 13 — operational constants |
Verification (overall)
After every step:
-
cargo xtask validate --skip-docker— pre-push gate green -
cargo xtask dev reseed— required after schema changes (Steps 2, 3, 7, 12) -
cargo nextest run --workspace— workspace tests green -
cargo xtask check-docs— Tier 1 docs clean -
cargo deny check— no new advisory regressions
After Step 13 (plan-wide):
-
Authorization integration matrix: per-handler test that for each (jurisdiction, role, expected outcome) the engine enforces correctly. Generated via test-harness macro (Step 8’s L2/L3 macros applied across services).
-
Cross-jurisdiction isolation: spawn two craig-cases instances with
JURISDICTION=georgiaandJURISDICTION=texas; confirm a Georgia caseworker JWT and a Texas caseworker JWT see different scope outcomes per their default policies. -
Replication convergence: admin-edit a ruleset via craig-security admin endpoint; verify all replicas refresh within 5s (RMQ event) or within TTL (1h fallback).
-
Default policy sufficiency:
cargo xtask dev reseedfollowed by full E2E suite — no test fails due to "missing policy" 403s. -
BOLA regression: per-handler "non-assigned caseworker is denied" + "supervisor sees scope per policy" + "admin always allowed" tests. ~280-340 across L1/L2/L3.
-
Manual emergency-revocation walkthrough: revoke a user’s role at the IdP; verify next JWT refresh denies them on subsequent requests (this is IdP behavior, not policy-engine, but worth verifying as part of operational runbook).
Documentation Updates
-
Per-step CHANGELOG entries
-
.claude/docs/security.md— record-level authz model, IdP-neutral identity layer, fail-closed engine semantics (after Step 4 / Step 11) -
.claude/docs/services.md—worker_identitiestable; new admin endpoints; denormalizedassigned_worker_subon 11 cross-service tables; new authz integration per service -
.claude/docs/shared-crates.md— craig-authz public API surface -
docs/modules/ROOT/pages/architecture.md— multi-jurisdictional authz section -
docs/modules/ROOT/pages/deployment-guide.adoc— required env vars after defaults removal; jurisdiction onboarding playbook -
docs/modules/ROOT/pages/jurisdiction-onboarding.adoc(new) — step-by-step guide for adopting CRAIG as a new jurisdiction (rulesets, default policies, admin-unit CSV, branding, env vars) -
ADRs 024, 025, 026, 027, 028
-
Step 14 archive entry in
archive.adoc -
.claude/CLAUDE.mdPhase Status row added
Errata
Step-by-step deviations from the original plan that landed in their respective MRs. Each entry cites the file:line that changed and the reason — so future readers can trace the decision back to the work that surfaced it.
Step 13b deviations (2026-05-10)
Step 13b ships the i18n + operational-constants tracks deferred from Step 13a.
-
icpc_deadline_days via settings, not ruleset metadata: the plan body called for reading the 84-day deadline from
{jurisdiction}-icpc-policyruleset metadata, but no such ruleset exists (Step 6 only shipped authz rulesets). Addedicpc_deadline_days: i64toServiceSettingsinstead — same env-var driven posture asadmin_unit_label, single boot-time read, no per-request ruleset evaluation overhead. A future MR can move it to ruleset metadata if jurisdiction-specific authz rules need to read it; right now it’s a single boot constant. -
PaymentPeriod enum: 3 variants — CalendarMonth (default), FiscalMonth (currently identical to CalendarMonth since federal fiscal-month boundaries align with calendar boundaries), IsoWeek (Monday-anchored 7-day windows). New
compute_period(today, payment_period)helper inservices/craig-financial/src/main.rsreplaces the inlinewith_day(1)+end_of_monthshape. -
AuthzContext bundle for too-many-args at icpc::update_icpc_request: adding the
IcpcDeadlineDaysextension pushed the handler past clippy’s 7-arg threshold. Replaced the separateauthz+jurisdictionextensions with the existingAuthzContextbundle (same Step 9 pattern). The handler readsauthz_ctx.engine+authz_ctx.jurisdictionthereafter. -
BFF ?worker= cleanup — UI state preserved: dropped the redundant caseworker-default-self path that round-tripped
claims.subto the backend (engine handles it post Step 8). Kept the supervisor-explicit-filter path (?worker=<sub>) since that’s a genuine "view as worker X" feature not covered by engine scope. UI toggle state computed separately so templates render the correct selection. -
Intake i18n: HTML genericization, not Fluent integration: craig-intake doesn’t host an i18n stack (no FTL files, no fluent-rs dep). Step 13b genericizes the inline strings ("County" → "Administrative Unit", "e.g. fulton" → "e.g. your county or region") rather than retrofitting a full Fluent i18n stack. Full intake-side i18n is a separate plan-level concern; Step 13b removes the Georgia-specific copy.
-
Fluent placeholder strings in craig-web: the 5 P0 i18n items the 2026-05-07 audit flagged were Fulton-specific example placeholders. Step 13b replaces them with jurisdiction-neutral examples in the existing FTL keys; no new keys required.
Step 13a deviations (2026-05-10)
Step 13’s plan body (Phase B) bundles three independent tracks: admin-unit externalization, BFF/intake i18n FTL extraction, and operational-constant externalization (ICPC deadline + payment-period). Step 13a ships the admin-unit track only; tracks two and three split out to a Step 13b follow-up MR (per the same Step-8/8b pattern from earlier in this plan).
-
CSV-as-single-source-of-truth: rather than the plan body’s
admin_units_for_jurisdiction(pool, jurisdiction)async function incrates/craig-reference/src/fips.rs, Step 13a keeps the existing staticGEORGIA_COUNTIESconst for sync callers (validation.rs::validate_admin_unit,translate.rs::admin_unit_to_fips) and adds CSV files (default_admin_units/{georgia,texas}.csv) as the canonical source for both the production xtask seed path AND the devstack seeder. craig-reference stays sqlx-free; the DB-driven path lives in craig-security where it already does (store::admin_units::list_unitssince Step 6). -
Texas counties: 254 entries seeded via
default_admin_units/texas.csv(closes #225). Not embedded as a static const infips.rs— keeps the file from ballooning to ~3000 lines and avoids duplicating the CSV. -
xtask seed-admin-units: idempotent UPSERT keyed on the existing
(name, jurisdiction) WHERE active = truepartial unique index.RETURNING (xmax = 0)reports per-row inserted/updated/skipped so operators can dry-run-equivalent re-runs. -
craig-seed via include_str!: instead of leaving the seeder’s existing
admin_units_for_state(State::Georgia)path untouched and adding Texas separately, Step 13a replaces it with a CSV-driven loop reading both jurisdictions viainclude_str!. Single source of truth between devstack reseed and the xtask path. RequiredDockerfilechange:COPY default_admin_units/ default_admin_units/so the macro resolves at container-build time. -
No new craig-security migration: the plan body called for
<TS>_seed_default_admin_units.sqlto auto-seed at install. Step 13a keeps the seeder + xtask paths instead — both are repeatable and idempotent, and a SQL migration that bakes 413 INSERT statements (159 + 254) is harder to evolve than the CSVs. Production deployments runcargo xtask seed-admin-unitsonce at install. -
Step 13b deferred scope (split for review tractability): BFF/intake i18n FTL extraction (5 P0 items from 2026-05-07 audit), ICPC 84-day deadline → ruleset metadata, payment-period config struct in
crates/craig-common/src/settings.rs+ craig-financial wiring, BFF?worker=query param removal post Step 8 engine-driven scope.
Step 11 deviations (2026-05-10)
Step 11 wires the engine into 60 handlers: craig-security (49 across 9 modules) + craig-rules (11). Plan body estimated ~28; actual is more than 2× because craig-security is broader than the plan section anticipated (admin units 9 + archive 3 + audit 3 + detection 8 + nist 5 + partners 9 + reviews 5 + signer_keys 5 + workers 1) and craig-rules has 11 endpoints (rule sets CRUD + import/export, evaluation, evaluation list).
-
Per-service bootstrap:
services/craig-security/src/authz_bootstrap.rsmirrors Step 9/10 (transitional ROPC; Plan E retires). -
DB-source for craig-rules: instead of HTTP self-loopback (
http://craig-rules:8001), craig-rules' authz engine readsrule_setsdirectly from its own Postgres pool via a newservices/craig-rules/src/authz_db_source.rs::DbRulesetSource. HTTP self-loopback raced the still-starting axum listener at boot AND recursed every authz check through the same handler chain (eachengine.checkforRuleSettriggered an HTTP call to the same service which itself calledengine.check). The DB source is bootstrap-correct, latency-free, and architecturally honest about craig-rules being the source of truth for its own data. Removed the now-redundantservices/craig-rules/src/authz_bootstrap.rsmodule. -
Cold-start hardening in craig-authz: three layers of self-healing close the bootstrap window where rulesets exist in DB but the engine cache hasn’t yet seen them. (1)
ZenAuthzEngine::bootretries the bulk-fetch up to 5× with exponential backoff (250ms → 4s, ~7.75s budget). (2) Newlazy_loadhelper incheck+auto_scope_list: on cache miss, attempts a singlerefresh_entryround-trip before failing closed. (3) Newspawn_post_boot_warmuptask: after axum binds, retriesbulk_refresh20× × 1s waiting for the seeder to populate rulesets. Wired into all 7 authz-using services. -
depends_on: craig-rules added in compose: craig-cases, craig-placement, craig-exchange, craig-financial, craig-reporting, craig-security all gain
depends_on: { craig-rules: { condition: service_healthy } }so their ROPC bootstrap doesn’t connection-refuse out the gate. craig-rules itself can’t depend on itself; the DB-source above sidesteps the question. -
26 admin-only rulesets created: Step 6 deliberately excluded the admin-internal ResourceTypes (admin_unit, major_change, audit_event, security_archive, security_alert, detection_rule, nist_control, partner, partner_api_key, partner_signer_key, security_review, rule_set, rule_evaluation) — 13 per jurisdiction × 2. Step 11 ships them, sourced from the Step-10-refined claim template. rule_set allows caseworker read+list (rule sets are referenced in case workflows); rule_evaluation allows caseworker create+read (case workflows trigger person-match etc. evaluations). 1 additional ruleset (worker_identity) sourced from the foster_home template (caseworker list scope=all, supporting the autocomplete-style UI on case assignment).
-
Shared admin-fallback helpers in craig-security:
services/craig-security/src/api/mod.rs::authz_check_or_admin_fallback+authz_list_or_admin_fallbackcollapse the engine.check + admin-role-fallback pattern at every site. Wired across all 9 security modules. craig-rules has the equivalentrules_authz_or_admin_fallbackplus the same per-handler admin-role bypass onListScope::Denied. -
AuthzContext bundle for too-many-args:
update_rule_set+import_rule_setin craig-rules useExtension<AuthzContext>to keep the per-handler arg count under clippy’s 7-arg threshold without#[allow]. Established Step 9 pattern. -
list_rule_sets / get_rule_set / get_rule_set_by_name now require authz: pre-Step-11 these were authenticated-only (any role). The engine path now requires admin/supervisor (default ruleset) — but
rule_setpolicy explicitly allows caseworker read+list since rule sets are referenced in case workflows. -
Existing role-gates removed: handlers no longer call
require_admin()/require_supervisor_or_above()/require_caseworker_or_above()/require_role(). 60 handlers across 10 modules.
Step 10 deviations (2026-05-10)
Step 10 wires the engine into 34 handlers: craig-financial (18 across payments, adjustments, claims, rates) + craig-reporting (16 across afcars, ncands, quality). Plan body estimated ~28; actual is higher because rate-table CRUD (5) + AFCARS workflow (8 incl. export/download) carry more endpoints than the section anticipated.
-
Per-service bootstrap:
services/craig-{financial,reporting}/src/authz_bootstrap.rsmirrors Step 9 (transitional ROPC; Plan E retires). -
Payment scope filter:
Paymentcarriesassigned_worker_sub(Step 7 denorm).ListPaymentsParams+CountPaymentsParamsgainassigned_worker_sub: Option<Uuid>; SQL filters on the denorm column. PaymentAdjustment also has the denorm but its list endpoint is small-N so Step 10 maps AssignedWorker scope to "empty page" rather than threading the column through the query (consistent with Step 9 KinshipOption deferral). -
Ruleset refinements (12 files: 6 admin-only resources × 2 jurisdictions): dropped caseworker rules entirely from Claim, RateTable, AfcarsSubmission, NcandsSubmission, QualityReview, DataQualityIssue. These are admin/supervisor-managed reference data + reporting submissions; caseworker → default deny → 403.
-
Action::Approve mapping:
approve_payment,approve_adjustment,approve_afcars,approve_ncands,submit_claim,transmit_afcars,transmit_ncandsuse Action::Approve. Submit and transmit are state-promoting operations on already-reviewed submissions; semantically Approve. -
Early authz on resolve_issue: DataQualityIssue policy gates on role + jurisdiction (no per-row attrs); the
resolve_issuehandler does the engine check with a skeleton ResourceRef before the row read so the existing 404-on-missing semantic is preserved for authorized callers (mirrors Step 9 retry_transaction). -
Existing role-gates removed: handlers no longer call
require_eligibility_worker_or_above()/require_supervisor_or_above()/require_admin()/require_caseworker_or_above()— engine.check() / auto_scope_list() are the entire access decision. 34 handlers across 7 modules.
Step 9 deviations (2026-05-09)
Step 9 wires the engine into 59 handlers across craig-placement (35 handlers in 7 modules) + craig-exchange (24 handlers in 4 modules). Plan body estimated ~42; actual is higher because health.rs is a CCWIS health-records module (6 handlers — not a /health liveness probe) and foster_homes.rs includes 9 training-management handlers in addition to home CRUD.
-
Per-service bootstrap:
services/craig-{placement,exchange}/src/authz_bootstrap.rsships the same transitional ROPC pattern Step 8 introduced for craig-cases.docker-compose.ymladdsCRAIG_PLACEMENTAUTHZ_BOOTSTRAP_*+CRAIG_EXCHANGEAUTHZ_BOOTSTRAP_*. Plan E retires all three modules together. -
ResourceType vocabulary: 7 placement types (Placement, KinshipOption, FosterHome, HomeDocument, EducationRecord, HealthRecord — and matching uses FosterHome::Read) + 4 exchange types (ExchangeTransaction, ExchangePartner, ExchangeAgreement, IcpcRequest — IcpcHomeStudy + IcpcAttachment are children of IcpcRequest). All already covered by the 70-file ruleset corpus from Step 6.
-
Scope filter — Placement only: the
Placementrow carriesassigned_worker_sub(Step 7 denorm), solist_placementstranslatesListScope::AssignedWorker(sub)into a SQL filter via a newassigned_worker_sub: Option<Uuid>column onListPlacementsParams+count_placements. Other resource types (FosterHome, HomeDocument, EducationRecord, HealthRecord, ExchangeTransaction, ExchangePartner, ExchangeAgreement, IcpcRequest) lack the denorm column; non-admin scopes map to "empty page" rather than a SQL filter. -
KinshipOption post-filter: KinshipOption has
assigned_worker_sub(Step 7 denorm) but its existinglist_kinship_optionsSQL takes onlycase_id— Step 9 post-filters in the handler rather than threading another optional param through, since kinship lists are bounded by case (small N). -
Action::Update for child-resource deletes:
delete_kinship_option,delete_home_documentuseAction::Updateon the parent (case for kinship; foster home for home_document) rather thanAction::Delete— same pattern as Step 8b’s case-scoped child deletes (deleting a kinship option is a case mutation, not a case deletion). -
FosterHome / HomeDocument caseworker access: Georgia’s default rulesets allow caseworker read/update on assigned foster homes via
claims.sub == resource.assigned_worker_sub. FosterHome has no assigned_worker denorm (Step 7 deviation: foster homes are licensable provider entities, not case-assigned), so the predicate evaluates false and caseworkers fall through to default deny. Practical effect: caseworkers currently cannot view foster homes via the engine path. Documented as a Step 12 (Phase A defaults removal) follow-up — the right fix is to either add a foster-home→assigned-worker linkage table or refine the ruleset so caseworker FosterHome::Read is admin-unit-scoped rather than worker-scoped. -
EducationRecord / HealthRecord child→case lookup deferred: same root cause as foster_home but with a Step 7-deferred remedy.
child_id-keyed records can be in multiple cases simultaneously; the right fix is a child→primary-case lookup at authz-eval time. Step 9 leavesassigned_worker_sub: Noneso caseworker access goes to default-deny; admin/supervisor/readonly remain functional. Lookup work deferred to a follow-up. -
ICPC child resources: home_study + attachment authz checks operate on the parent IcpcRequest (their
idparameter is the IcpcRequest id).submit_home_studyusesAction::Update;get_home_study/list_attachments/download_attachmentuseAction::Read;upload_attachmentusesAction::Update. -
No new test crate: Step 8 / 8b ship hand-written L2 smoke tests. Step 9 follows the same pattern; Step 11+ revisits proc-macro test generation.
-
Existing role-gates removed: handlers no longer call
require_caseworker_or_above()/require_supervisor_or_above()/require_admin()/require_icpc_coordinator_or_above()— engine.check() / auto_scope_list() are the entire access decision. 59 handlers across 11 modules.
Step 8b deviations (2026-05-09)
Step 8b is the continuation MR Step 8’s deviations promised. Wires the Plan A engine pattern into 4 additional services/craig-cases/src/api/* modules covering 26 handlers:
-
investigations.rs(5 handlers — create, list, get, update, submit_safety_assessment) — parent resource with its ownassigned_worker. UsesResourceType::Investigation. List path usesauto_scope_list(returns empty page forAssignedSupervisor— Investigation has no supervisor field; documented in handler). -
contacts.rs(5 handlers — create, list, get, update, delete) — case-scoped child. All authz routes through parentcase_resource_ref. -
court_orders.rs(7 handlers — create, list, upload_document, download_document, get, update, delete) — case-scoped child.upload_document+download_documentauthz on parent case. -
case_plans.rs(9 handlers — create_case_plan, list_case_plans, update_case_plan, approve_case_plan, create_task, update_task, list_tasks, get_task, delete_task) — case-scoped child + tasks scoped via plan→case chain.approve_case_planusesAction::Approve.
Helper visibility lifted: services/craig-cases/src/api/cases.rs::case_resource_ref raised from private to pub(super) so sibling modules build the parent ResourceRef without duplicating field coercion. Single source of truth for attrs shape across all case-scoped child handlers.
Local Jurisdiction newtype removed: Step 8 left a duplicate pub struct Jurisdiction(pub String) in services/craig-cases/src/api/mod.rs. Step 8b replaces it with pub use craig_authz::Jurisdiction so handlers and engine.check(…) use the same type. Pre-Step-8b the local type compiled but would have caused engine misses at runtime if any test path mixed the two.
Existing role-gates removed across the 4 modules: handlers no longer call claims.require_caseworker_or_above() / require_supervisor_or_above(). Engine.check() / auto_scope_list() are the entire access decision — role logic lives inside JDM rulesets via 'admin' in claims.roles, etc. Same posture as Step 8.
Step 8c — deferred handler modules (planned)
Step 8b defers 6 modules to a continuation Step 8c MR. Reasoning: each carries domain-specific complexity that warrants per-module review rather than templated wiring:
-
services/craig-cases/src/api/referrals.rs— intake-side (entry point from craig-intake forwarding). Authz semantics differ from case-assigned model: pre-screening posture; noassigned_workeruntil referral-to-investigation conversion. Needs separateResourceType::Referralpolicy design. -
services/craig-cases/src/api/reports.rs— partner-submitted reports with PII encryption. Authz interacts with intake-source authentication (anonymous public form vs. authenticated partner) + the existing report→person link audit trail (ADR-019). -
services/craig-cases/src/api/persons.rs— PII-encrypted records (ssn_last_four, etc. — platform-stab-2 §D9). Authz query interacts with the blind-index search path; needs careful design to avoid plaintext cache leaks. -
services/craig-cases/src/api/report_persons.rs— ADR-019 report-person linking (suggestions / link / unlink). Authz on parent report (when wired in 8c) + secondary check on the linked person (when 8c does persons). -
services/craig-cases/src/api/report_attachments.rs— report-scoped child. Trivial wiring oncereports.rsis wired (mirrorscontacts.rs→contact_attachments.rsshape). -
services/craig-cases/src/api/contact_attachments.rs— contact-scoped child. Trivial; deferred only becausecontacts.rswas wired in 8b but attachments weren’t yet — keeping module-pair churn together.
Step 8c is filed as a follow-up issue with the same single-MR-full-scope shape as Step 8b. Independent of Plan E (no service-to-service dependency); independent of Plan B Step 3 (encryption). Can ship at any time after Step 8b lands.
Step 8 deviations (2026-05-09)
-
Module scope: plan body listed 14 modules with ~57 handlers. Step 8 wires only
services/craig-cases/src/api/cases.rs(8 handlers: create_case, list_cases, get_case, update_case, list_household, add_household_member, list_milestones, batch_lookup). The remaining 13 modules (investigations, referrals, reports, contacts, court_orders, case_plans, case_plan_tasks, persons, report_persons, report_attachments, contact_attachments, screening_decisions, disposition_follow_ups) defer to a continuation MR (Step 8b). Reasoning: Step 8 establishes the integration pattern (bootstrap + Jurisdiction extension + ResourceRef helper + ListScope→SQL mapping + invalidation subscriber + TTL refresh + ROPC bootstrap) — Step 8b template-applies the pattern across the remaining modules. Splitting reduces per-MR review surface + lets Step 8’s foundation land before the bigger templating sweep. -
L2 macro generators + L3 fixture-driven regression tests: plan body called for
crates/craig-authz/src/test_macros.rsproc-macros generating#[authz_smoke_test]+ L3 fixture tests. Step 8 ships hand-written tests inservices/craig-cases/tests/api/authz_smoke.rsinstead (3 tests covering admin / supervisor / caseworker scope outcomes against the live engine). Macro-generation is deferred to Step 8b along with the remaining handlers — at that point the macro produces ~80 tests automatically. Hand-written 3 in this MR demonstrates the integration works end-to-end without taking on the macro-generation complexity in the same MR. -
Pre-Plan-E ROPC bootstrap: craig-cases needs a service-account bearer to fetch authz rulesets from craig-rules at boot. Plan E (service-identity, filed at !235) ships
OidcServiceToken+client_credentialsto handle this, but Plan E hasn’t started yet. Step 8 ships a transitionalservices/craig-cases/src/authz_bootstrap.rsthat does ROPC against Keycloak using devstack admin credentials to obtain a bearer for the boot bulk-fetch. Same anti-pattern asservices/craig-intake/src/api/service_token.rs::KeycloakServiceToken; explicitly marked "REMOVE WHEN PLAN E LANDS" in the file header. Devstackdocker-compose.ymlsets the four env vars (CRAIG_CASES__KEYCLOAK_TOKEN_URL,…_USER,…_PASSWORD,…_CLIENT_ID); production deployments unset → engine boots with empty cache and fail-closes every request until Plan E lands. Documented as Plan E Step 3’s first concrete consumer. -
Cache-invalidation subscriber + TTL refresh registration: Step 5 plan errata documented that registering subscribers in service main.rs files was deferred to Steps 8-11. Step 8 wires both for craig-cases here. Routing-key bug fixed in
crates/craig-authz/src/invalidation.rs: bind onruleset.changed.(one full word per RabbitMQ topic semantics) rather thanruleset.changed.<prefix>(which would require dashes-as-separator); jurisdiction-prefix filtering moved into the handler. -
Ruleset emit shape: scope outputs added: Step 6’s rulesets emitted
{allow, reason}for every rule. Step 8 surfaced thatauto_scope_listneeds{scope: …}shape. Regenerated all 70 ruleset files to emit{allow, scope, reason}from every rule. Read/Update/Create rules emito_scope: "assigned_worker"as a placeholder; List rules emit the actual policy scope ("all"/"assigned_worker"/"assigned_supervisor"/"denied"). Engine reads the right field based on action. -
BatchLookupResponse contract change: per Plan A § D15, added
unauthorized_count: usize+partial_auth: boolfields. Pre-1.0 + serde defaults guarantee the addition is a non-breaking wire change for existing clients (older clients ignore the new fields). -
Existing role-gate removal: handlers no longer call
claims.require_caseworker_or_above()— engine.check() / auto_scope_list() are the entire access decision, with role logic now living inside the JDM rulesets via'admin' in claims.rolesetc.
Step 7 deviations (2026-05-09)
-
Table count: plan body called for "11 tables across placement/financial/exchange". Step 7 shipped 7 (placements, kinship_options, payments, payment_adjustments, icpc_requests, icpc_home_studies, icpc_attachments). The 4 deferrals are architecturally justified, not scope-shedding:
-
home_documents(placement) — keyed onhome_id/foster_home_id. Foster homes are licensable provider entities; authz here is licensing-staff/admin scope, not case-assigned worker. Addingassigned_worker_subwould be misleading. -
claiming_records(financial) — quarterly aggregate (CFS-2 submission); no per-case scoping, nocase_idcolumn. Authz is admin-only. Skip. -
education_records(placement) — keyed onchild_id+ nullableplacement_id. A child can be in multiple cases simultaneously (siblings, multiple investigations); there’s no single "assigned_worker per education record." Skip — child_id-keyed records use a child→primary-case lookup at authz-eval time (Step 8 design choice when handlers wire in). -
health_records(placement) — same as education_records.Documented in
services/craig-placement/migrations/<TS>_assigned_worker_sub.sqlheader.
-
-
Inbox handler subscriber semantics: plan body called for "competing-consumer for at-least-once update semantics" using
subscribe. Implemented exactly as spec’d. UPDATE WHERE clauses includeassigned_worker_sub IS DISTINCT FROM $1so duplicate deliveries from competing consumers are no-ops.craig_mq::handle_idempotentlywraps the handler for full at-least-once correctness per platform-stab-2 §D3. -
Backfill xtask shape: ships at
xtask/src/cmd/backfill_cross_service_assignment.rsmatching plan §D6. Direct DB connection (4 pools: cases + placement + financial + exchange) — no service API churn.cargo xtask backfill-cross-service-assignment [--limit N]. Idempotent re-runs cheap. -
Placement subscriber added; financial + exchange extended: plan body had placement listed in the per-service main.rs registration. craig-placement had no inbound subscribers prior; this MR adds the first. craig-financial + craig-exchange had subscribers for other events; we extend their routing-key list + dispatch.
-
Manual-verification of backfill: backfill against current devstack populated 17 placements + 7 kinship_options + 45 payments + 1 icpc_request + 1 icpc_home_study from 7 seeded cases. payment_adjustments + icpc_attachments updated 0 rows because the seeder doesn’t currently produce any.
Step 6 deviations (2026-05-09)
-
Resource type count: plan body’s CHANGELOG draft estimated "~30 per jurisdiction"; Step 6 shipped 35 per jurisdiction (70 total). The extra 5 per jurisdiction are the icpc/exchange/rate_table types that fall under caseworker-visible scope. 12 admin-internal types (
audit_event,security_alert,security_archive,security_review,detection_rule,nist_control,major_change,admin_unit,worker_identity,partner,partner_api_key,partner_signer_key,rule_set,rule_evaluation) intentionally excluded — they fail-closed via cache miss, which is the right default for admin-only types. Documented inrulesets/README.adoc. Step 11 (security + rules engine adoption) will revisit if explicit policies are needed there. -
JDM expression syntax: plan §D5 example used
"contains 'admin'"for input rule expressions; Step 6 verified that zen-engine 0.54 actually accepts the cleaner'admin' in claims.rolesform as the input field expression (returns bool), with"== true"as the rule value. The §D5 example wire-shape was approximately right but not literal; the shipped rulesets carry the verified syntax. -
Generator script not committed: a one-shot Bash generator at
/tmp/gen-authz-ruleset.shstamped out the 70 JSON files. Discarded after use — rulesets are version-controlled artifacts, not generated code. Re-running the generator would produce the same output (template is deterministic) but ops should treat the JSON files as the source of truth. -
README format:
rulesets/README.adoc(asciidoc per project convention). Initial draft was markdown; corrected mid-MR after user feedback.
Step 5 deviations (2026-05-09)
-
Cache-invalidation exchange: plan’s Files-list called for
crates/craig-mq/src/lib.rsto defineCACHE_INVALIDATIONS_EXCHANGE: &str = "craig.cache_invalidations"as a separate topic exchange. Step 5 reuses the existingcraig.eventstopic exchange instead — same fan-out semantics viasubscribe_exclusive(auto-delete queues bound onruleset.changed.<name>routing key) without an extra exchange to declare in bootstrap. The existingPublisher::publish_in_txpath stages the events through the outbox unchanged; consumers bind narrowly via topic-pattern. Documented incrates/craig-authz/src/invalidation.rsmodule doc. -
6-services-main.rs subscriber wiring: plan’s Files-list called for "All 6 consuming services'
src/main.rs— register cache-invalidation subscriber viasubscribe_exclusive`". Step 5 ships the subscriber primitive (`craig_authz::spawn_invalidation_subscriber) but does NOT register it in the 6 services because no service consumescraig-authzyet — Steps 8-11 wire the engine into individual services. Subscriber registration becomes part of each service-adoption step (Step 8 wires craig-cases + registers; Step 9 wires craig-placement + craig-exchange + registers; etc.) so the subscriber ships alongside its first consumer in each service. -
authz.policy.changedevent: plan’s § D14 schema includes a separateauthz.policy.changedevent for-authz-ruleset mutations. Step 5 ships the more generalruleset.changed.<name>event (which covers all rule-set CRUD); theauthz.policy.changeddistinction is a routing-key namespace concern that becomes meaningful only when a separate audit subscriber wants to filter authz-only changes. Deferred to Step 8 (cases adoption) when audit-event subscribers actually exist. -
TTL task cadence: plan called for "TTL refresh task with jitter". Step 5 ships TTL/10 sweep cadence (floor 30s) with ±25% jitter. Defaults: 1h TTL → 6m sweeps. Override via
CRAIG_<SVC>__AUTHZ_POLICY_TTL_SECONDS=<n>. -
Test count: plan called for "~10 new tests"; Step 5 shipped 10 new (6 invalidation + 4 rules-client). Math reconciles.
Open questions
All resolved during plan-shaping (2026-05-07 design conversation):
-
Identity normalization shape — destructive migration; pre-1.0 + no production data. Resolved.
-
Policy DSL — zen-engine/JDM (already in workspace; gold-standard pattern in CRAIG). Resolved.
-
Cross-service authz path — denormalize via
case.assignment_changedevents. Resolved. -
Soft-delete behavior — predicate first; return deleted row to authorized callers (Option A). Resolved.
-
Fail-closed when no policy matches. Resolved.
-
Cache invalidation — RMQ via
subscribe_exclusive+ TTL fallback (1h default). Resolved. -
Test coverage — L1 (engine unit) + L2 (handler smoke) + L3 (fixture-driven regression). Resolved.
-
Default policies content — Georgia + Texas as deliberately-divergent regression fixtures, not authoritative. Resolved.
-
BFF route guards — backend-only; BFF is a thin renderer. Resolved.
-
IdP neutrality — no admin API; lazy
worker_identitiesfrom JWT claims. Resolved. -
Action vocabulary — Read/List/Create/Update/Delete/Approve. Resolved.
Risks
| Risk | Mitigation |
|---|---|
Step 2 destructive migration on a deployment with real production data |
Pre-1.0 + no production = safe today; document in ADR-025 that first-production deployments need per-jurisdiction data-import tooling, NOT this destructive migration. ADR-025 includes a runbook section. |
Cache miss / boot-load failure produces fail-closed-everything outage |
Boot-fail bails clearly with operator-facing log; cache miss at request time is logged + alerted; TTL refresh is the safety net for partial events. Same fail-closed-with-clear-error pattern as platform-stab-2’s encryption-mode-required guard. |
Default fixtures don’t match a future jurisdiction’s actual roles |
L3 regression layer documents that fixtures are CRAIG-curated; jurisdiction-onboarding playbook (Step 13 doc) instructs adopters to fork-and-customize. |
Eventually-consistent denormalization window denies new worker until propagation completes |
Acceptable: stale window fails closed in conservative direction (denies new worker, not old). Documented in ADR-023. |
Steps 8-11 touch ~140 sites; large coordinated diff risks merge conflicts against in-flight feature work |
Per-service rollout (Steps 9/10/11 split by service); each MR is reviewer-tractable. Coordinate with feature-work cadence; target quiet windows. |
LIST handlers ignoring |
Step 13 cleans up BFF callsites in same MR. Backend already auto-scopes correctly. |
zen-engine ruleset complexity grows beyond what jurisdictions can author by hand |
gorules.io has a visual JDM editor; document in jurisdiction-onboarding playbook. |
Auth middleware DB upsert (Step 3) adds 1-2ms latency to every authenticated request |
Indexed lookup; ON CONFLICT path is the common case after warmup. If perf becomes a concern, add an in-process LRU cache with TTL. |
After this plan lands
-
Record-level authorization enforced at every authenticated handler (~140 sites); BOLA/IDOR surface closed
-
CRAIG is genuinely multi-jurisdictional: states, tribes, counties onboard with rulesets + CSV + env vars, no code changes
-
Identity model normalized:
claims.subUUID is the canonical worker identity; preferred_username is presentation-only -
IdP-neutral: any OIDC-compatible IdP works; no admin API coupling
-
Plan B (
pii-and-partner-edge-hardening.adoc) becomes safe to execute -
GitOps follow-up plan (
gitops-jurisdiction-config.adoc) becomes the natural next step -
ADR-009 (mobile/offline-capable client) becomes safe to design — predicate is well-defined for offline-sync conflict resolution
-
Foundation laid for Phase 11 portals (constituent, foster parent, provider) — they extend the policy engine with their own role types
CRAIG can credibly claim federal-compliance posture for record-level access control across arbitrary jurisdictions.