Skip to content

phase 13.x.7: encryption admin CLI + rotate-key endpoint#23

Merged
nah414 merged 8 commits into
mainfrom
phase-13x-encryption-admin
Jun 1, 2026
Merged

phase 13.x.7: encryption admin CLI + rotate-key endpoint#23
nah414 merged 8 commits into
mainfrom
phase-13x-encryption-admin

Conversation

@nah414

@nah414 nah414 commented May 29, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the two ergonomic gaps left by Phase 13.x.6 (PR #21) by shipping the phoenix admin generate-encryption-key CLI + POST /v1/admin/encryption/rotate-key admin endpoint, both backed by a shared phoenix/ledger/keygen.py::generate_age_keypair() primitive.

What ships

  • New primitive: phoenix/ledger/keygen.pyGeneratedKeyPair dataclass + generate_age_keypair() + 3 typed errors (KeyGenError / KeyGenPathConflict / KeyGenWriteError).
  • CLI subcommand: phoenix admin generate-encryption-key [--name SLUG] [--force] [--keys-dir PATH].
  • Admin endpoint: POST /v1/admin/encryption/rotate-key with optional {name, force} body; returns paths + fingerprints + next_step.
  • New permission: ActorPermissions.can_rotate_encryption_key (default deny; granted to bootstrap actors).
  • Audit events: admin.encryption.rotate.success + 9 granular admin.encryption.rotate.error.<kind> types.
  • phoenix/ledger/encryption_age.py drops the stale "(Phase 13.x.7 CLI)" parenthetical.
  • phoenix/ledger/README.md ceremony docs reference the new CLI + endpoint (Setup section + Rotation section + What's-shipped/not-shipped table).

NOT shipped (deferred follow-ups)

  • Batch decrypt-and-re-encrypt of existing ENCRYPTED_OPT_IN ledger rows on rotation — substantial DB-transaction work; gets its own follow-up slot.
  • POST /v1/admin/encryption/reload zero-downtime encryptor reload — daemon-restart pattern preserved.
  • Identity revocation / cleanup.

Tests added

20 new across 4 test files:

  • tests/cognition/test_keygen.py (9) — primitive happy-path / conflict / force / fingerprint / POSIX-mode / name-validation
  • tests/integration/test_admin_encryption_rotate_key.py (5) — endpoint happy-path / default-name / 409 / force / 403
  • tests/cli/test_admin_generate_encryption_key.py (3) — CLI happy-path / conflict / force
  • tests/unit/test_permissions_phase13x7.py (3) — default-deny / explicit-grant / existing-flags-unchanged

Project-wide pytest: 1316 passed, 43 skipped, 0 failures.

Spec / plan

  • Design: docs/superpowers/specs/2026-05-28-phase-13x7-encryption-admin-design.md (99cf4f4 on main)
  • Plan: docs/superpowers/plans/2026-05-28-phase-13x7-encryption-admin.md (d8211b6 on main)

Test plan

  • Reviewer confirms pytest tests/cognition/test_keygen.py tests/integration/test_admin_encryption_rotate_key.py tests/cli/test_admin_generate_encryption_key.py tests/unit/test_permissions_phase13x7.py -v all green
  • Reviewer confirms mypy --strict clean on the 6 touched modules
  • Reviewer eyeballs the next_step daemon-restart message
  • Reviewer confirms CHANGELOG entry under [1.1.0.dev0] below 13.x.4

Generated with Claude Code

Summary by Sourcery

Add an encryption key generation primitive and expose it via both CLI and admin API, guarded by new permissions and documented in the ledger README and changelog.

New Features:

  • Introduce phoenix.ledger.keygen.generate_age_keypair() and GeneratedKeyPair for generating and writing age keypairs with typed error handling.
  • Add phoenix admin generate-encryption-key CLI subcommand to generate local encryption keypairs without contacting the daemon.
  • Add POST /v1/admin/encryption/rotate-key admin endpoint to rotate encryption keys in-process and return key paths, fingerprints, and a next-step message.
  • Add can_rotate_encryption_key permission flag, granted to bootstrap admin actors, to gate access to the rotate-key endpoint.

Enhancements:

  • Update AgePromptEncryptor error messaging to reference the new keygen primitive as an alternative to the CLI.
  • Update ledger README to describe the new CLI and admin endpoint, clarify shipped vs deferred encryption features, and rotation workflow.
  • Add detailed changelog entry for Phase 13.x.7 describing the new encryption admin surfaces and their limitations.

Tests:

  • Add tests for the keygen primitive covering naming, conflicts, POSIX modes, and fingerprinting.
  • Add integration tests for the rotate-key admin endpoint, including default naming, conflict, force, and permission behavior.
  • Add CLI tests for the new generate-encryption-key subcommand covering success, conflict, and force overwrite.
  • Add unit tests for the new can_rotate_encryption_key permission flag defaults and explicit grant behavior.

nah414 and others added 8 commits May 28, 2026 20:19
POST /v1/admin/encryption/rotate-key generates a fresh age keypair
via the Task 1 keygen primitive, writes it under the conventional
PHOENIX_ENCRYPTION_KEYS_DIR, and returns paths + 16-hex fingerprints.
Permission-gated on both is_admin (standard admin chain) and the
Task 3 can_rotate_encryption_key flag (defense in depth).

Body fields:
  name (default 'rotation-<date>') -- slug for the keypair files
  force (default false)            -- overwrite vs 409 on conflict

Response includes next_step copy explaining the daemon-restart
requirement: existing data stays decryptable with the prior identity
and new encrypts pick up both recipients per encryption_age's
multi-recipient lossless-rotation design.

Audit events emit recipient_fingerprint + recipient_path on success
and per-error variants on each failure path; never the identity
secret.

Tests cover happy path, default name, 409 conflict, force override,
and the non-admin 403 path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented May 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements a shared age keypair generation primitive, exposes it via a new phoenix admin generate-encryption-key CLI subcommand and a permission-gated POST /v1/admin/encryption/rotate-key admin endpoint, wires in a new rotate-key permission + audit events, and updates docs/tests accordingly.

Sequence diagram for POST /v1/admin/encryption/rotate-key

sequenceDiagram
    actor Operator
    participant AdminAPI as rotate_encryption_key
    participant Authn as _admin_authn
    participant Identity as extract_or_bootstrap
    participant Safety as verify_request
    participant Perms as get_permissions_registry
    participant Keygen as generate_age_keypair
    participant Audit as emit_admin_audit

    Operator->>AdminAPI: POST /v1/admin/encryption/rotate-key
    AdminAPI->>Authn: _admin_authn(request, authorization)
    Authn->>Identity: extract_or_bootstrap(authorization)
    Identity-->>Authn: actor
    Authn->>Safety: verify_request(actor, action_key="admin.mutate")
    Safety-->>Authn: ok
    Authn->>Perms: get_permissions_registry().get(actor.name)
    Perms-->>Authn: perms (can_rotate_encryption_key)
    Authn-->>AdminAPI: actor

    AdminAPI->>Keygen: generate_age_keypair(keys_dir=None, name, force)
    Keygen-->>AdminAPI: GeneratedKeyPair

    AdminAPI->>Audit: emit_admin_audit(event_type="admin.encryption.rotate.success")
    AdminAPI-->>Operator: 200 OK {paths, fingerprints, next_step}
Loading

File-Level Changes

Change Details Files
Introduce shared age keypair generation primitive for filesystem-backed encryption keys.
  • Add GeneratedKeyPair dataclass capturing identity/recipient paths and fingerprints.
  • Implement generate_age_keypair() that lazily imports pyrage, generates an X25519 keypair, writes identity/recipient files following Phoenix’s directory/layout conventions, and enforces POSIX 0o600 mode on identity files where applicable.
  • Add validation of keypair name slugs, path resolution helpers, fingerprinting of recipient text, and typed errors KeyGenError, KeyGenPathConflict, and KeyGenWriteError for conflict and filesystem failures.
  • Use $PHOENIX_ENCRYPTION_KEYS_DIR/default_keys_dir() when keys_dir is omitted and support force overwrite semantics.
phoenix/ledger/keygen.py
Add phoenix admin generate-encryption-key CLI subcommand that wraps the keygen primitive locally.
  • Register new generate-encryption-key subcommand under the admin command group with --name, --force, and --keys-dir arguments.
  • Implement _cmd_generate_encryption_key to call generate_age_keypair(), map KeyGenPathConflict and other KeyGenError/ImportError to rc=1 with helpful stderr messages, and print identity/recipient paths and fingerprint plus a daemon-restart next_step message to stdout.
  • Wire the handler into the admin HANDLERS mapping so the CLI entry point dispatches correctly.
phoenix/cli/commands/admin.py
phoenix/cli/entry.py
Expose a new POST /v1/admin/encryption/rotate-key admin endpoint backed by the keygen primitive with detailed auth, permission, and audit behavior.
  • Define RotateKeyPayload (optional name, defaulting to rotation-<date>, and force) and a _default_name() helper to keep server-side naming consistent and testable.
  • Implement _admin_authn that runs the standard admin auth chain (extract_or_bootstrap, verify_request, kill switch, rate limiting, require_admin) plus an extra can_rotate_encryption_key permission check, emitting granular audit events (...error.identity, ...error.auth, ...error.permission, ...error.kill_switch, ...error.rate_limit, ...error.privilege) and mapping failures to appropriate HTTP status codes.
  • Implement rotate_encryption_key route on admin_router that calls generate_age_keypair(), maps KeyGenPathConflict to HTTP 409 and KeyGenError/ImportError to HTTP 500 with corresponding audit events (including ...error.conflict, ...error.keygen, ...error.pyrage_missing), emits admin.encryption.rotate.success with non-secret metadata on success, and returns paths, fingerprints, and a next_step daemon-restart guidance string.
  • Export RotateKeyPayload and rotate_encryption_key and import the module in phoenix/admin/__init__.py so the router is registered on app startup.
phoenix/admin/encryption_admin.py
phoenix/admin/__init__.py
tests/integration/test_admin_encryption_rotate_key.py
Extend permissions model to support rotate-encryption-key as a separately gateable capability and verify defaults via tests.
  • Add can_rotate_encryption_key: bool = False to ActorPermissions with documentation indicating it gates the rotate-key endpoint and defaults to deny.
  • Grant can_rotate_encryption_key=True in _default_permissions_for for bootstrap/admin-tier actors so they can use the new endpoint by default.
  • Add unit tests to confirm default deny, explicit grant behavior, and that adding the new flag does not change existing default flags.
phoenix/safety/permissions.py
tests/unit/test_permissions_phase13x7.py
Update encryption docs and error messaging to reference the new CLI/endpoint and clarify shipped vs not-yet-shipped behavior.
  • Update CHANGELOG.md with Phase 13.x.7 entry describing the CLI, rotate-key endpoint, shared keygen primitive, new permission, audit events, and deferred follow-ups plus test coverage summary.
  • Revise phoenix/ledger/README.md setup and rotation sections to show phoenix admin generate-encryption-key instead of manual age-keygen commands, describe the rotate-key endpoint’s role, and restructure the shipped vs not-shipped sections through 13.x.7.
  • Tweak encryptor_from_default_layout’s missing-identity error message to also reference programmatic key generation via phoenix/ledger/keygen.py::generate_age_keypair().
CHANGELOG.md
phoenix/ledger/README.md
phoenix/ledger/encryption_age.py
Add targeted tests for keygen primitive and CLI behavior using a fake pyrage implementation.
  • Introduce tests/cognition/test_keygen.py using a sys.modules-injected fake pyrage.x25519 to exercise happy path, named keys, recipients-dir creation, POSIX file mode, fingerprinting, conflict vs force semantics, default keys dir resolution, and name validation errors for generate_age_keypair().
  • Add tests/cli/test_admin_generate_encryption_key.py to drive _cmd_generate_encryption_key directly with a fake pyrage, validating success output, conflict exit code and stderr guidance, and --force overwrite behavior without touching a live daemon.
tests/cognition/test_keygen.py
tests/cli/test_admin_generate_encryption_key.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The _validate_name error message in phoenix/ledger/keygen.py mentions only leading '.' or '-' as disallowed, but the regex actually forbids any non-alphanumeric first character (including '_'); consider tightening the message or relaxing the regex so behavior and text match.
  • In _admin_authn (encryption_admin), get_permissions_registry().get(actor.name) is used without a fallback; it might be safer to handle a missing registry entry explicitly (e.g., defaulting to deny or raising a clearer error) before accessing can_rotate_encryption_key.
  • The fake pyrage Identity/Recipient helpers are duplicated across multiple test files; extracting a shared test helper module would reduce repetition and keep future changes to the stubbed crypto surface in one place.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `_validate_name` error message in `phoenix/ledger/keygen.py` mentions only leading '.' or '-' as disallowed, but the regex actually forbids any non-alphanumeric first character (including '_'); consider tightening the message or relaxing the regex so behavior and text match.
- In `_admin_authn` (encryption_admin), `get_permissions_registry().get(actor.name)` is used without a fallback; it might be safer to handle a missing registry entry explicitly (e.g., defaulting to deny or raising a clearer error) before accessing `can_rotate_encryption_key`.
- The fake `pyrage` Identity/Recipient helpers are duplicated across multiple test files; extracting a shared test helper module would reduce repetition and keep future changes to the stubbed crypto surface in one place.

## Individual Comments

### Comment 1
<location path="tests/integration/test_admin_encryption_rotate_key.py" line_range="137-144" />
<code_context>
+# Tests.
+
+
+class TestRotateKeyEndpoint:
+    def test_happy_path_returns_200(
+        self,
+        fake_pyrage: Any,
+        isolated_runtime: Path,
+    ) -> None:
+        keys_dir = Path(os.environ["PHOENIX_ENCRYPTION_KEYS_DIR"])
+        with TestClient(app) as client:
+            resp = client.post(
+                "/v1/admin/encryption/rotate-key",
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test for actors with is_admin=True but can_rotate_encryption_key=False

Since the implementation performs a second permission check after `require_admin`, this scenario should be covered by an integration test that uses an admin actor with `can_rotate_encryption_key=False`, calls `POST /v1/admin/encryption/rotate-key`, and asserts a 403 with an appropriate missing-permission message. That will verify that admin status alone is not enough to rotate keys and guard against regressions in the authorization logic.

Suggested implementation:

```python
class TestRotateKeyEndpoint:
    def test_happy_path_returns_200(
        self,
        fake_pyrage: Any,
        isolated_runtime: Path,
    ) -> None:
        keys_dir = Path(os.environ["PHOENIX_ENCRYPTION_KEYS_DIR"])
        with TestClient(app) as client:
            resp = client.post(
                "/v1/admin/encryption/rotate-key",
                json={"name": "rotation-2026-05-28"},
            )
        assert resp.status_code == 200, resp.text
        body = resp.json()
        # Paths land in the configured keys dir under the expected layout.
        assert body["identity_path"].endswith("identity-rotation-2026-05-28.txt")
        assert body["recipient_path"].endswith("rotation-2026-05-28.pub")
        # Fingerprints are 16 lowercase hex chars and match each other
        # (keygen primitive uses the recipient text for both sides).
        assert len(body["identity_fingerprint"]) == 16

    def test_admin_without_rotate_permission_gets_403(
        self,
        fake_pyrage: Any,
        isolated_runtime: Path,
    ) -> None:
        # Create an admin actor that is not allowed to rotate encryption keys.
        actor = mint_bootstrap_actor("admin-no-rotate")
        actor.is_admin = True
        actor.can_rotate_encryption_key = False

        payload = actor.to_payload()
        auth_header = "Phoenix-Actor " + base64.b64encode(
            json.dumps(payload).encode()
        ).decode("ascii")

        with TestClient(app) as client:
            resp = client.post(
                "/v1/admin/encryption/rotate-key",
                json={"name": "rotation-2026-05-28"},
                headers={"Authorization": auth_header},
            )

        assert resp.status_code == 403, resp.text
        body = resp.json()
        # Admin status alone is not sufficient; missing rotate permission is enforced.
        assert "can_rotate_encryption_key" in body.get("detail", "")

```

If the test suite uses a shared helper to construct the `Authorization` header from an actor (e.g. a function wrapping the `mint_bootstrap_actor(...).to_payload()` + base64 logic), replace the inline header construction in `test_admin_without_rotate_permission_gets_403` with that helper to match existing conventions.

If the error response format differs (for example, `{"error": {"code": "...", "message": "..."}}` or a different key than `"detail"`), update the final assertion to check the correct field and structure, keeping the core intent: asserting a 403 status and that the message indicates the missing `can_rotate_encryption_key` permission.
</issue_to_address>

### Comment 2
<location path="tests/integration/test_admin_encryption_rotate_key.py" line_range="138-147" />
<code_context>
+    def test_happy_path_returns_200(
</code_context>
<issue_to_address>
**suggestion (testing):** Consider a test for keygen failure (KeyGenError/ImportError) returning 500 from the endpoint

To fully cover the endpoint’s documented behaviour, add an integration test where `generate_age_keypair` fails. For example, monkeypatch `phoenix.admin.encryption_admin.generate_age_keypair` to:

- Raise `KeyGenError` and assert a 500 with the expected "keygen failed" detail
- (Optionally) raise `ImportError` and assert a 500 with the import error detail

This will verify the error-to-HTTP 500 mapping and make the failure modes reliable for clients.

Suggested implementation:

```python
from phoenix.admin.encryption_admin import KeyGenError


class TestRotateKeyEndpoint:

```

```python
    def test_happy_path_returns_200(
        self,
        fake_pyrage: Any,
        isolated_runtime: Path,
    ) -> None:
        keys_dir = Path(os.environ["PHOENIX_ENCRYPTION_KEYS_DIR"])
        with TestClient(app) as client:
            resp = client.post(
                "/v1/admin/encryption/rotate-key",
                json={"name": "rotation-2026-05-28"},
            )
        assert resp.status_code == 200

    def test_keygen_error_returns_500(
        self,
        fake_pyrage: Any,
        monkeypatch,
        isolated_runtime: Path,
    ) -> None:
        def _raise_keygen_error(*args, **kwargs):
            raise KeyGenError("keygen failed")

        import phoenix.admin.encryption_admin as encryption_admin

        monkeypatch.setattr(
            encryption_admin,
            "generate_age_keypair",
            _raise_keygen_error,
        )

        with TestClient(app) as client:
            resp = client.post(
                "/v1/admin/encryption/rotate-key",
                json={"name": "rotation-2026-05-28"},
            )

        assert resp.status_code == 500
        body = resp.json()
        assert body["detail"] == "keygen failed"

    def test_import_error_returns_500(
        self,
        fake_pyrage: Any,
        monkeypatch,
        isolated_runtime: Path,
    ) -> None:
        def _raise_import_error(*args, **kwargs):
            raise ImportError("age backend not available")

        import phoenix.admin.encryption_admin as encryption_admin

        monkeypatch.setattr(
            encryption_admin,
            "generate_age_keypair",
            _raise_import_error,
        )

        with TestClient(app) as client:
            resp = client.post(
                "/v1/admin/encryption/rotate-key",
                json={"name": "rotation-2026-05-28"},
            )

        assert resp.status_code == 500
        body = resp.json()
        assert "age backend not available" in body["detail"]

```

1. If `KeyGenError` is defined in a different module than `phoenix.admin.encryption_admin`, adjust the import line accordingly (e.g. `from phoenix.encryption import KeyGenError`).
2. If the endpoint wraps error messages differently (for example, prefixes them or nests them under another field), tweak the assertions on `body["detail"]` to match the actual response shape.
3. If the project uses a different fully-qualified path for `generate_age_keypair`, update the `import phoenix.admin.encryption_admin as encryption_admin` accordingly so the `monkeypatch.setattr` targets the correct function.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +137 to +144
class TestRotateKeyEndpoint:
def test_happy_path_returns_200(
self,
fake_pyrage: Any,
isolated_runtime: Path,
) -> None:
keys_dir = Path(os.environ["PHOENIX_ENCRYPTION_KEYS_DIR"])
with TestClient(app) as client:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a test for actors with is_admin=True but can_rotate_encryption_key=False

Since the implementation performs a second permission check after require_admin, this scenario should be covered by an integration test that uses an admin actor with can_rotate_encryption_key=False, calls POST /v1/admin/encryption/rotate-key, and asserts a 403 with an appropriate missing-permission message. That will verify that admin status alone is not enough to rotate keys and guard against regressions in the authorization logic.

Suggested implementation:

class TestRotateKeyEndpoint:
    def test_happy_path_returns_200(
        self,
        fake_pyrage: Any,
        isolated_runtime: Path,
    ) -> None:
        keys_dir = Path(os.environ["PHOENIX_ENCRYPTION_KEYS_DIR"])
        with TestClient(app) as client:
            resp = client.post(
                "/v1/admin/encryption/rotate-key",
                json={"name": "rotation-2026-05-28"},
            )
        assert resp.status_code == 200, resp.text
        body = resp.json()
        # Paths land in the configured keys dir under the expected layout.
        assert body["identity_path"].endswith("identity-rotation-2026-05-28.txt")
        assert body["recipient_path"].endswith("rotation-2026-05-28.pub")
        # Fingerprints are 16 lowercase hex chars and match each other
        # (keygen primitive uses the recipient text for both sides).
        assert len(body["identity_fingerprint"]) == 16

    def test_admin_without_rotate_permission_gets_403(
        self,
        fake_pyrage: Any,
        isolated_runtime: Path,
    ) -> None:
        # Create an admin actor that is not allowed to rotate encryption keys.
        actor = mint_bootstrap_actor("admin-no-rotate")
        actor.is_admin = True
        actor.can_rotate_encryption_key = False

        payload = actor.to_payload()
        auth_header = "Phoenix-Actor " + base64.b64encode(
            json.dumps(payload).encode()
        ).decode("ascii")

        with TestClient(app) as client:
            resp = client.post(
                "/v1/admin/encryption/rotate-key",
                json={"name": "rotation-2026-05-28"},
                headers={"Authorization": auth_header},
            )

        assert resp.status_code == 403, resp.text
        body = resp.json()
        # Admin status alone is not sufficient; missing rotate permission is enforced.
        assert "can_rotate_encryption_key" in body.get("detail", "")

If the test suite uses a shared helper to construct the Authorization header from an actor (e.g. a function wrapping the mint_bootstrap_actor(...).to_payload() + base64 logic), replace the inline header construction in test_admin_without_rotate_permission_gets_403 with that helper to match existing conventions.

If the error response format differs (for example, {"error": {"code": "...", "message": "..."}} or a different key than "detail"), update the final assertion to check the correct field and structure, keeping the core intent: asserting a 403 status and that the message indicates the missing can_rotate_encryption_key permission.

Comment on lines +138 to +147
def test_happy_path_returns_200(
self,
fake_pyrage: Any,
isolated_runtime: Path,
) -> None:
keys_dir = Path(os.environ["PHOENIX_ENCRYPTION_KEYS_DIR"])
with TestClient(app) as client:
resp = client.post(
"/v1/admin/encryption/rotate-key",
json={"name": "rotation-2026-05-28"},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider a test for keygen failure (KeyGenError/ImportError) returning 500 from the endpoint

To fully cover the endpoint’s documented behaviour, add an integration test where generate_age_keypair fails. For example, monkeypatch phoenix.admin.encryption_admin.generate_age_keypair to:

  • Raise KeyGenError and assert a 500 with the expected "keygen failed" detail
  • (Optionally) raise ImportError and assert a 500 with the import error detail

This will verify the error-to-HTTP 500 mapping and make the failure modes reliable for clients.

Suggested implementation:

from phoenix.admin.encryption_admin import KeyGenError


class TestRotateKeyEndpoint:
    def test_happy_path_returns_200(
        self,
        fake_pyrage: Any,
        isolated_runtime: Path,
    ) -> None:
        keys_dir = Path(os.environ["PHOENIX_ENCRYPTION_KEYS_DIR"])
        with TestClient(app) as client:
            resp = client.post(
                "/v1/admin/encryption/rotate-key",
                json={"name": "rotation-2026-05-28"},
            )
        assert resp.status_code == 200

    def test_keygen_error_returns_500(
        self,
        fake_pyrage: Any,
        monkeypatch,
        isolated_runtime: Path,
    ) -> None:
        def _raise_keygen_error(*args, **kwargs):
            raise KeyGenError("keygen failed")

        import phoenix.admin.encryption_admin as encryption_admin

        monkeypatch.setattr(
            encryption_admin,
            "generate_age_keypair",
            _raise_keygen_error,
        )

        with TestClient(app) as client:
            resp = client.post(
                "/v1/admin/encryption/rotate-key",
                json={"name": "rotation-2026-05-28"},
            )

        assert resp.status_code == 500
        body = resp.json()
        assert body["detail"] == "keygen failed"

    def test_import_error_returns_500(
        self,
        fake_pyrage: Any,
        monkeypatch,
        isolated_runtime: Path,
    ) -> None:
        def _raise_import_error(*args, **kwargs):
            raise ImportError("age backend not available")

        import phoenix.admin.encryption_admin as encryption_admin

        monkeypatch.setattr(
            encryption_admin,
            "generate_age_keypair",
            _raise_import_error,
        )

        with TestClient(app) as client:
            resp = client.post(
                "/v1/admin/encryption/rotate-key",
                json={"name": "rotation-2026-05-28"},
            )

        assert resp.status_code == 500
        body = resp.json()
        assert "age backend not available" in body["detail"]
  1. If KeyGenError is defined in a different module than phoenix.admin.encryption_admin, adjust the import line accordingly (e.g. from phoenix.encryption import KeyGenError).
  2. If the endpoint wraps error messages differently (for example, prefixes them or nests them under another field), tweak the assertions on body["detail"] to match the actual response shape.
  3. If the project uses a different fully-qualified path for generate_age_keypair, update the import phoenix.admin.encryption_admin as encryption_admin accordingly so the monkeypatch.setattr targets the correct function.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: feba9d88c8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +230 to +233
result = generate_age_keypair(
keys_dir=None,
name=name,
force=payload.force,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject primary key names in rotate endpoint

When an admin calls this rotate endpoint with {"name":"primary","force":true}, the shared keygen primitive resolves that name to identity.txt and recipients/primary.pub, so the endpoint overwrites the live identity rather than adding a rotation identity. After the advertised daemon restart, encryptor_from_default_layout() loads only identity.txt, and any existing ENCRYPTED_OPT_IN rows encrypted for the old primary key become undecryptable unless the operator has an out-of-band backup; the rotate endpoint should reject primary or otherwise avoid replacing the loaded identity.

Useful? React with 👍 / 👎.

Comment thread phoenix/ledger/keygen.py
Comment on lines +192 to +194
identity_path.write_text(identity_text + "\n", encoding="utf-8")
if sys.platform != "win32":
os.chmod(identity_path, _POSIX_IDENTITY_MODE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Create identity files with restrictive mode initially

On POSIX systems with a permissive umask, write_text() creates the private age identity with the process default permissions before the later chmod(0o600). During that window, or if the process crashes between the write and chmod, the secret key can be left world/group-readable even though this helper is responsible for key material; open the file with mode 0o600 at creation time (and handle force atomically) so the secret is never written with broader permissions.

Useful? React with 👍 / 👎.

nah414 added a commit that referenced this pull request May 29, 2026
Each Phoenix actor gets their own age identity + recipient pair.
Ledger gains prompt_encryption_actor_id column (NULL = shared key,
preserves 13.x.6 back-compat). Per-actor encryptor registry with
disk-load fallback. CLI + admin endpoint gain --actor / actor_id.

Four open tensions surfaced for Adam review:
- actor-bootstrap (auto-generate on enroll vs opt-in admin command)
- self-serve-rotation (admin-gated recommended)
- cache-invalidation (evict on rotate recommended)
- migration-direction (mixed-state ledger acceptable)

Out of scope: batch migration of existing shared-key rows;
cross-actor delegated decrypt; hardware-backed actor keys.

Design-only this session; implementation deferred to future session
after PR #23 (13.x.7) merges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nah414 nah414 merged commit 9764d54 into main Jun 1, 2026
10 checks passed
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