Skip to content

fix(policy): API policy reloads survive a restart and are recorded (F21) - #12

Merged
Harshith029 merged 2 commits into
mainfrom
policy-durability
Oct 24, 2026
Merged

Harshith029 merged 2 commits into
mainfrom
policy-durability

Conversation

@Harshith029

Copy link
Copy Markdown
Owner

What and why

Review finding F21: POST /tenants/{tenant}/policy only swapped the compiled policy in memory. A restart silently put the configured file back, and nothing recorded that the policy changed, or that anyone tried. The docs listed it as a limitation ("a restart forgets API policy reloads").

This finishes the WIP on f21-policy-durability (18 days old, "NOT READY TO MERGE: the regression tests … are not written"), ported onto the current code, with its tests and three design changes:

WIP Here
File name <tenant>.yaml, after a new [A-Za-z0-9._-] rule on tenant names SHA-256 of the tenant, tenant stored inside. Every tenant name SENTINEL_API_TOKENS accepts still works; acme/ACME cannot share a file on Windows or macOS
Concurrency none reloads serialized; without it a slow v2 save could land after v3's, so v3 is enforced but v2 comes back on restart (the test shows this with the lock removed)
Durability write + rename staged, fsynced, renamed, directory fsynced on POSIX

Behaviour:

  • An applied policy is saved under ${SENTINEL_DATA_DIR}/policies/ before it takes effect. A failed save changes nothing and answers 500.
  • At startup a saved policy wins only with a higher policy_version (the reload's own rule), so an operator who bumps the configured file gets the file.
  • A saved policy that cannot be read or no longer loads stops sentinel serve and sentinel check with an error naming the file (checked by hand: check exits 1, serve refuses to start).
  • Every reload becomes a PolicyChanged span (applied / ignored / rejected / failed, versions, SHA-256 of the submitted text) in a policy-change run owned by that tenant, exported at severity medium, shown on the dashboard, and logged as a POLICY line.

deploy/compose.yaml already mounts its volume at SENTINEL_DATA_DIR, so saved policies persist with no deployment change.

Testing

tests/test_policy_durability.py (9 tests): survives a restart; a configured file with a higher version wins; applied/ignored/rejected each recorded; the record belongs to that tenant only (another tenant cannot see it; audit export fields); tenant names like ../escape, acme, ACME, Acme Corp/EU each get their own file inside the directory; a failed save changes nothing (manager and API); concurrent reloads; a corrupt saved file stops startup.

  • The concurrency test fails without the lock (restart returns v2 while v3 was enforced), checked by swapping the lock for a no-op.
  • End to end against a real sentinel serve: admin reload to v42 applied, agent token refused (403), the reload listed in the tenant's runs, still v42 after a restart.
  • Full suite: 699 passed, 12 skipped, 1 expected xfail. ruff, mypy --strict clean. Docs site builds.

Rollback note (in the changelog): versions before this cannot replay policy-change runs, since they do not know the event type.

Security invariants

  • Fail closed: a malformed or unreadable saved policy stops startup rather than quietly enforcing an older one; a failed save leaves the active policy untouched.
  • Operator separation unchanged: only SENTINEL_ADMIN_TOKEN reaches the reload (403 otherwise, verified).
  • Forensic spans stay immutable; the policy text itself is not copied into the record, only its SHA-256.

🤖 Generated with Claude Code

POST /tenants/{tenant}/policy only swapped the compiled policy in
memory. A restart silently put the configured file back, and nothing
recorded that the policy had changed, or that anyone had tried.

Finishes the work-in-progress on f21-policy-durability, ported onto the
current code, with the regression tests it listed and three changes to
its design:

- Saved under ${SENTINEL_DATA_DIR}/policies/ BEFORE the policy takes
  effect: staged, fsynced, then renamed (and the directory fsynced on
  POSIX), so a crash leaves the old file or the new. A failed save
  changes nothing; the API answers 500.
- Files are named by SHA-256 of the tenant, with the tenant stored
  inside, instead of <tenant>.yaml behind a new [A-Za-z0-9._-] rule. Any
  tenant name SENTINEL_API_TOKENS accepts still works, no name can
  escape the directory, and "acme" and "ACME" cannot share a file on a
  case-insensitive file system (Windows, macOS).
- Reloads are serialized. Interleaved, a slow save of v2 could land
  after v3's: v3 enforced, v2 on disk, and the restart brought back v2.
  The concurrency test shows exactly that with the lock removed.

At startup a saved policy replaces the configured one only when its
policy_version is higher, the reload's own rule. A saved policy that
cannot be read or no longer loads stops serve and check with an error
naming the file, as a broken configured file does.

Every reload becomes a PolicyChanged span (applied, ignored, rejected
or failed; versions; SHA-256 of the submitted text) in a policy-change
run owned by that tenant, exported at severity medium, rendered on the
dashboard, and logged as a POLICY security line. The registry gains
apply_if_newer so the submitted text is parsed once.

Verified end to end against a real `sentinel serve`: an admin reload to
v42 is applied, an agent token is refused (403), the reload appears in
the tenant's runs, and after a restart the tenant is still on v42.
Docs: README limitations, and the site's policy, limitations,
deployment and forensics pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Harshith029
Harshith029 merged commit fac6370 into main Oct 24, 2026
9 checks passed
@Harshith029 Harshith029 mentioned this pull request Oct 24, 2026
1 task done
@Harshith029
Harshith029 deleted the policy-durability branch October 24, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant