# Phase 4: Auth — COMPLETED

## Goal
API key validation and per-namespace permission checking. Pure logic, no I/O.

## Files
- `src/auth.rs`
- `src/auth/apikey.rs`
- `src/auth/permissions.rs`

## Completed Work

### auth.rs (root)
- [x] `trait Authenticator` — validate key, check permission
  - `fn authenticate(&self, key: &ApiKey) -> Result<AuthSession>`
  - `fn check_permission(&self, namespace: &NamespaceId, key: &ApiKey, perm: Permission) -> Result<()>`
- [x] `AuthSession` struct — bundles `NamespaceId` + `PermissionSet` for per-connection caching
- [x] Re-export `ApiKeyStore` and `PermissionSet`
- [x] `MockAuthenticator` (`#[cfg(test)]`) — single-key mock for downstream module tests

### auth/apikey.rs
- [x] `ApiKeyStore` struct — holds key→(namespace, permissions) mapping built from config
- [x] `impl Authenticator for ApiKeyStore`
- [x] Constant-time key comparison via `subtle::ConstantTimeEq`
- [x] `check_permission` verifies key belongs to the claimed namespace (prevents cross-namespace abuse)

### auth/permissions.rs
- [x] `PermissionSet` — `u8` bitmask (Subscribe=0b001, Publish=0b010, Query=0b100), `Copy`, zero-allocation
- [x] `contains(perm)` — O(1) membership check (single bitwise AND)
- [x] `check(perm) -> Result<()>` — returns `Ok` or `Err(PermissionDenied)`
- [x] `Display` impl — formats as `{Subscribe, Publish}` for logging

## Design Decisions Made
- **`authenticate` returns `AuthSession`, not just `NamespaceId`** — the TCP session caches both namespace and permissions after the Connect handshake. This avoids repeated key lookups on the hot path; permission checks become a single bitmask AND via `session.permissions.check(perm)`.
- **`check_permission` remains for HTTP** — stateless callers that need authenticate-and-check in one shot. TCP sessions should use the cached `AuthSession` instead.
- **`PermissionSet` as `u8` bitmask** — with only 3 `Permission` variants, a bitmask is simpler and faster than `HashSet`. `Copy`, no allocation, O(1) checks.
- **`ApiKeyStore` uses `Arc<[KeyEntry]>` not `Arc<HashMap>`** — linear scan with constant-time comparison for each entry. Avoids HashMap's short-circuiting `PartialEq` which could leak timing info. Acceptable for small namespace counts (tens, not thousands).
- **`subtle` crate for constant-time comparison** — standard Rust crypto ecosystem crate, zero transitive dependencies, better audited than hand-rolled XOR loops.
- **Single API key per namespace for v1** — config models one `api_key` per `NamespaceConfig`. Multiple keys per namespace added in Phase 4.1.
- **Key rotation out of scope for v1** — the `Arc<[KeyEntry]>` design could support atomic swap later if needed. Programmatic key management added in Phase 4.1.

## Test Summary
- 20 unit tests (10 permissions + 10 apikey), all passing
- `cargo clippy` clean, `cargo fmt` clean

### permissions.rs tests (10)
- `empty_set_contains_nothing` — EMPTY set rejects all permissions
- `from_slice_all_permissions` — all three permissions present
- `from_slice_subset` — Publish+Query only, Subscribe absent
- `check_granted_returns_ok` — granted permission returns Ok
- `check_denied_returns_permission_denied` — missing permission returns PermissionDenied
- `from_empty_slice` — empty slice equals EMPTY constant
- `duplicate_permissions_are_idempotent` — repeated permissions don't change the set
- `display_empty` — formats as `{}`
- `display_single` — formats as `{Publish}`
- `display_all` — formats as `{Subscribe, Publish, Query}`

### apikey.rs tests (10)
- `authenticate_valid_key_returns_session` — valid key returns AuthSession with correct namespace and permissions
- `authenticate_returns_correct_namespace_and_permissions` — second namespace maps correctly, permissions match config
- `authenticate_invalid_key` — unknown key returns AuthFailed
- `authenticate_wrong_length_key` — shorter and longer keys rejected (exercises length-mismatch path in constant-time comparison)
- `check_permission_granted` — all three permissions pass for fully-permissioned namespace
- `check_permission_denied` — Subscribe denied for Publish+Query-only namespace
- `check_permission_invalid_key` — unknown key returns AuthFailed
- `check_permission_key_namespace_mismatch` — valid key claiming wrong namespace returns AuthFailed
- `empty_config_rejects_all_keys` — store built from empty config rejects everything
- `api_key_store_is_send_and_sync` — compile-time assertion that ApiKeyStore satisfies Authenticator trait bounds

---

## Phase 4.1 — Programmatic API Key Management

### Goal

Enable multiple named API keys per namespace with per-key permissions, a persistent runtime key store, and mutation methods on `ApiKeyStore` for creating, revoking, updating, and listing keys. Config-file keys become immutable "seed" keys; runtime-created keys are fully manageable via admin API (Plan 25) and CLI (Plan 23).

### Prerequisites

- Phase 4 complete (current `ApiKeyStore` with `ArcSwap`, `Authenticator` trait, `PermissionSet`)
- Plan 17 Phase 17.5 complete (`SharedConfig` / `ArcSwap` infrastructure)

### Design Decisions (apply to all sub-phases)

- **`_seed:{namespace}` naming for config keys** — config-file keys are auto-named `_seed:default`, `_seed:analytics`, etc. The `_seed:` prefix is reserved and rejected by key name validation. This avoids collisions with user-chosen names (unlike `config-{ns}`, which could clash if a namespace were named `config-something`).
- **Key store lives at `{data_dir}/_system/keys.toml`** — separates runtime metadata from topic data directories. The `_system/` subdirectory is created on startup if absent. Future runtime state files (e.g., consumer group checkpoints) can live here too.
- **Mutation lock** — all mutating operations (create, revoke, update) acquire a `tokio::sync::Mutex` before reading state, applying changes, persisting to disk, and swapping. This prevents concurrent admin requests from racing (read-mutate-persist-swap is not atomic without a lock). Read-only operations (`list_keys`, `get_key`, `find_session`) remain lock-free via `ArcSwap::load()`.
- **`expires_at` field on `ManagedKey`** — stored but not enforced. Adding it now avoids a `keys.toml` format migration when key expiration is implemented. Value is `Option<u64>` (millis since epoch). `None` means no expiration. The create API accepts an optional `expires_at`; the field is persisted and returned in listings. Enforcement (background sweep that revokes expired keys) is out of scope.
- **`find_session()` still uses linear scan** — with named keys, the store holds more entries per namespace. Linear scan with constant-time comparison remains correct for security. At typical key counts (tens, not thousands), performance is negligible.
- **`persist_runtime_keys()` writes all runtime keys** — not incremental. The full `keys.toml` is rewritten on every mutation. Acceptable for the low frequency of key management operations.
- **`generate_api_key()` uses `rand::OsRng`** — cryptographically secure, 32 alphanumeric characters. No external key generation dependency.
- **Config key immutability is enforced at the `ApiKeyStore` layer** — the admin API doesn't need to check `source` itself; mutation methods return `Error::ConfigKeyImmutable`.

---

### Phase 4.1a — Data Model & Validation

**Goal:** Define the core types for named API keys without changing any runtime behavior. All existing tests pass unchanged.

#### ManagedKey

```rust
/// A named API key with permissions, belonging to a namespace.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManagedKey {
    pub name: String,                  // unique per namespace, e.g. "ci-publisher"
    pub api_key: ApiKey,               // the secret string (≥24 chars)
    pub namespace: NamespaceId,
    pub permissions: PermissionSet,
    pub source: KeySource,
    pub created_at: u64,               // millis since epoch
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub expires_at: Option<u64>,       // millis since epoch, None = never expires
}

/// Where a key originated — determines mutability via API.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum KeySource {
    /// Loaded from config.toml. Immutable via admin API.
    Config,
    /// Created at runtime via admin API. Fully manageable.
    Runtime,
}
```

#### KeyEntry changes

Extend the existing `KeyEntry` with `name`, `source`, `created_at`, `expires_at`:

```rust
struct KeyEntry {
    name: String,               // NEW — key name (e.g. "ci-publisher" or "_seed:default")
    namespace: NamespaceId,
    permissions: PermissionSet,
    key_bytes: Vec<u8>,
    source: KeySource,          // NEW — Config or Runtime
    created_at: u64,            // NEW — millis since epoch
    expires_at: Option<u64>,    // NEW — millis since epoch, None = never
}
```

#### KeyInfo (redacted view)

```rust
/// Redacted key info returned by list/get (never exposes full secret).
#[derive(Debug, Clone, Serialize)]
pub struct KeyInfo {
    pub name: String,
    pub namespace: NamespaceId,
    pub permissions: PermissionSet,
    pub source: KeySource,
    pub created_at: u64,
    pub expires_at: Option<u64>,
    pub api_key_redacted: String,  // first 4 + "..." + last 4 chars
}
```

#### Key name validation

- Regex: `[a-zA-Z0-9_-]{1,64}`
- Must be unique within the namespace (config + runtime combined)
- Names starting with `_seed:` are reserved for auto-generated config key names

#### Error variants

Add to `Error` enum: `KeyNameExists`, `DuplicateApiKey`, `ConfigKeyImmutable`, `KeyNotFound`, `KeyStorePersistFailed`

#### Tasks

- [ ] Define `ManagedKey` struct in `src/auth/apikey.rs`
- [ ] Define `KeySource` enum in `src/auth/apikey.rs`
- [ ] Define `KeyInfo` struct in `src/auth/apikey.rs`
- [ ] Extend `KeyEntry` with `name`, `source`, `created_at`, `expires_at` fields
- [ ] Add `validate_key_name(name: &str) -> Result<()>` — regex `[a-zA-Z0-9_-]{1,64}`, reject `_seed:` prefix
- [ ] Add `redact_api_key(key: &str) -> String` — first 4 + `...` + last 4 chars
- [ ] Add error variants to `Error` enum: `KeyNameExists`, `DuplicateApiKey`, `ConfigKeyImmutable`, `KeyNotFound`, `KeyStorePersistFailed`
- [ ] Update `build_entries_from_config()` to populate `name` as `_seed:{namespace}`, `source: Config`, `created_at: 0`, `expires_at: None`
- [ ] Verify all existing tests pass — no behavioral changes

**Verification:** New types compile. Existing `ApiKeyStore` works unchanged (new `KeyEntry` fields populated but not read by existing code). All 20 auth tests pass.

---

### Phase 4.1b — Key Store File (Persistence)

**Goal:** Implement `keys.toml` loading and atomic persistence. The key store file lives at `{data_dir}/_system/keys.toml`.

#### Key store file format

```toml
[[keys]]
name = "ci-publisher"
namespace = "default"
api_key = "generated-secret-key-here-0000"
permissions = ["Publish"]
created_at = 1711185330000

[[keys]]
name = "dashboard-reader"
namespace = "default"
api_key = "another-secret-key-here-0000"
permissions = ["Query", "Fetch"]
created_at = 1711185331000
expires_at = 1743200000000
```

#### File safety

- `{data_dir}/_system/` directory created on startup if absent
- Atomic writes via temp file (`keys.toml.tmp`) + `rename()` — no partial writes on crash
- File permissions warning on startup (reuse `check_config_permissions()`)
- Missing or empty `keys.toml` on startup is not an error — proceeds with config keys only

#### Startup merge flow

1. Load `config.toml` → build config keys with `_seed:{namespace}` names, `source: Config`, `created_at: 0`
2. Load `{data_dir}/_system/keys.toml` → build runtime keys with `source: Runtime`
3. Validate: no name collisions within any namespace, no duplicate API key strings globally
4. Merge both into a single `Vec<KeyEntry>`, store in `ArcSwap`
5. If `keys.toml` references a namespace not in config, log a warning per orphaned key

#### Tasks

- [ ] Implement `ApiKeyStore::load_key_store(data_dir: &Path) -> Result<Vec<ManagedKey>>` — parse `{data_dir}/_system/keys.toml`
- [ ] Implement `persist_runtime_keys(entries: &[KeyEntry], data_dir: &Path) -> Result<()>` — filter `source: Runtime`, serialize to TOML, atomic write via temp + rename
- [ ] Implement `ApiKeyStore::from_config_and_key_store(config, runtime_keys) -> Self` — merge config + runtime entries, validate no collisions
- [ ] Create `{data_dir}/_system/` directory on startup in `main.rs` if absent
- [ ] Add file permissions warning for `keys.toml` on startup
- [ ] Update startup flow in `main.rs`: call `load_key_store()`, pass to `from_config_and_key_store()`
- [ ] Unit test: `load_key_store` with valid `keys.toml` returns correct entries (including `expires_at`)
- [ ] Unit test: `load_key_store` with missing file returns empty vec
- [ ] Unit test: `load_key_store` with missing `_system/` directory returns empty vec
- [ ] Unit test: `persist_runtime_keys` round-trips through `load_key_store`
- [ ] Unit test: startup merge — config keys + runtime keys combined, no collisions
- [ ] Unit test: startup merge — duplicate name across config + runtime keys returns error
- [ ] Unit test: startup merge — orphaned runtime key (namespace not in config) logs warning
- [ ] Unit test: `find_session` matches any key in a multi-key namespace
- [ ] Unit test: `authenticate` returns correct `AuthSession` for each key in same namespace (different permissions)
- [ ] Unit test: key name validation accepts valid names, rejects invalid names and `_seed:` prefix

**Verification:** Key store loads and persists correctly. Startup merges config + runtime keys. Atomic writes prevent corruption. All existing tests pass unchanged.

---

### Phase 4.1c — Mutation Methods & Key Generation

**Goal:** Add CRUD methods to `ApiKeyStore` with a mutation lock for concurrency safety. These methods are called by the admin API (Plan 25) and cluster replication (Plan 24).

#### Mutation lock

```rust
pub struct ApiKeyStore {
    entries: ArcSwap<Vec<KeyEntry>>,
    mutation_lock: tokio::sync::Mutex<()>,  // NEW — serializes mutate+persist+swap
    data_dir: PathBuf,                       // NEW — for persist_runtime_keys()
}
```

All mutating operations acquire `mutation_lock` before proceeding:
1. Acquire `mutation_lock`
2. Load current entries from `ArcSwap`
3. Validate the mutation
4. Apply the mutation (in-memory)
5. Persist runtime keys to `{data_dir}/_system/keys.toml` (atomic write)
6. `ArcSwap::store()` the new entries
7. Release `mutation_lock`

If persist fails, the mutation is rolled back (ArcSwap is not updated). Read-only operations (`list_keys`, `get_key`, `find_session`) remain lock-free.

#### API extensions

```rust
impl ApiKeyStore {
    // --- Mutations (acquire mutation_lock) ---
    pub async fn create_key(&self, key: ManagedKey) -> Result<()>;
    pub async fn revoke_key(&self, namespace: &NamespaceId, name: &str) -> Result<()>;
    pub async fn update_key_permissions(&self, namespace: &NamespaceId, name: &str, permissions: PermissionSet) -> Result<()>;

    // --- Reads (lock-free via ArcSwap::load) ---
    pub fn list_keys(&self, namespace: &NamespaceId) -> Vec<KeyInfo>;
    pub fn get_key(&self, namespace: &NamespaceId, name: &str) -> Option<KeyInfo>;

    // --- Key generation ---
    pub fn generate_api_key() -> ApiKey;  // cryptographically random 32-char alphanumeric

    // --- Replication apply methods (acquire mutation_lock) ---
    pub async fn apply_replicated_key_create(&self, key: ManagedKey) -> Result<()>;
    pub async fn apply_replicated_key_revoke(&self, namespace: &NamespaceId, name: &str) -> Result<()>;
    pub async fn apply_replicated_key_update(&self, namespace: &NamespaceId, name: &str, permissions: PermissionSet) -> Result<()>;
}
```

Note: mutation methods no longer take `data_dir` as a parameter — it's stored on the struct.

**Error cases:**
- `create_key` with duplicate name in namespace → `Error::KeyNameExists`
- `create_key` with duplicate API key string → `Error::DuplicateApiKey`
- `revoke_key` / `update_key_permissions` on `source: Config` → `Error::ConfigKeyImmutable`
- `revoke_key` / `get_key` with unknown name → `Error::KeyNotFound`
- Persist failure → `Error::KeyStorePersistFailed` (mutation rolled back)

**Replication apply methods** are idempotent — `apply_replicated_key_create` for an existing key is a no-op, `apply_replicated_key_revoke` for a missing key is a no-op. Used by cluster followers (Plan 24 Phase 24.9a).

#### Tasks

- [ ] Add `mutation_lock: tokio::sync::Mutex<()>` and `data_dir: PathBuf` to `ApiKeyStore` struct
- [ ] Update constructors (`from_config`, `from_config_and_key_store`) to accept `data_dir` and initialize `mutation_lock`
- [ ] Implement `create_key()` — acquire lock, validate name/uniqueness/permissions, add entry, persist, swap
- [ ] Implement `revoke_key()` — acquire lock, validate source is Runtime, remove entry, persist, swap
- [ ] Implement `update_key_permissions()` — acquire lock, validate source is Runtime, update permissions, persist, swap
- [ ] Implement `list_keys()` — lock-free, return `Vec<KeyInfo>` filtered by namespace
- [ ] Implement `get_key()` — lock-free, return `Option<KeyInfo>` for a specific name
- [ ] Implement `generate_api_key()` — 32-char cryptographically random alphanumeric string via `rand::OsRng`
- [ ] Implement `apply_replicated_key_create()` — idempotent create + persist (no-op if exists)
- [ ] Implement `apply_replicated_key_revoke()` — idempotent revoke + persist (no-op if missing)
- [ ] Implement `apply_replicated_key_update()` — idempotent update + persist (no-op if missing)
- [ ] Unit test: `create_key` adds key, `list_keys` returns it with redacted secret
- [ ] Unit test: `create_key` with `expires_at` set, verify it round-trips through list/get
- [ ] Unit test: `create_key` with duplicate name returns `KeyNameExists`
- [ ] Unit test: `create_key` with duplicate API key string returns `DuplicateApiKey`
- [ ] Unit test: `revoke_key` on runtime key succeeds
- [ ] Unit test: `revoke_key` on config key returns `ConfigKeyImmutable`
- [ ] Unit test: `revoke_key` on unknown name returns `KeyNotFound`
- [ ] Unit test: `update_key_permissions` on runtime key succeeds
- [ ] Unit test: `update_key_permissions` on config key returns `ConfigKeyImmutable`
- [ ] Unit test: `generate_api_key` returns 32-char string, all alphanumeric
- [ ] Unit test: concurrent `create_key` calls are serialized (no lost writes)
- [ ] Unit test: `apply_replicated_key_create` is idempotent (applying twice is a no-op)
- [ ] Unit test: `apply_replicated_key_revoke` is idempotent (revoking nonexistent is a no-op)
- [ ] Unit test: persist failure rolls back — `ArcSwap` unchanged after I/O error

**Verification:** CRUD methods work correctly with mutation lock. Concurrent mutations are serialized. Replication apply methods are idempotent. All existing tests pass.

---

### Phase 4.1d — Hot-Reload Integration & Main Wiring

**Goal:** Ensure config hot-reload correctly preserves runtime keys, wire the full startup flow in `main.rs`, and update `from_config()` to serve as a backward-compatible convenience constructor for tests.

#### Hot-reload flow

When config reloads (Plan 17):
1. Config keys are rebuilt from the new `config.toml` (auto-named `_seed:{namespace}`)
2. Runtime keys are loaded from the current `ArcSwap` state (filter `source: Runtime`)
3. Merged and re-swapped — config changes take effect, runtime keys preserved
4. If a namespace is removed from config, runtime keys for that namespace become orphaned → log warning per key, do NOT delete

#### Tasks

- [ ] Update `reload_from_config()` to preserve `source: Runtime` entries during config-triggered rebuilds
- [ ] Update `from_config()` to be a convenience constructor that passes empty runtime keys and a temp dir (for tests)
- [ ] Wire full startup flow in `main.rs`: create `_system/` dir, `load_key_store()`, `from_config_and_key_store(config, runtime_keys, data_dir)`
- [ ] Unit test: hot-reload preserves runtime keys while updating config keys
- [ ] Unit test: hot-reload with namespace removal logs warning for orphaned runtime keys
- [ ] Unit test: hot-reload with new namespace does not affect runtime keys in other namespaces
- [ ] Unit test: hot-reload with config key permission change updates only `_seed:` keys
- [ ] Integration test: create runtime key → reload config → runtime key still works for auth
- [ ] Integration test: server restart with existing `keys.toml` → runtime keys available immediately

**Verification:** Hot-reload and runtime key management coexist safely. Runtime keys survive config reloads and server restarts. Orphaned keys warned, not deleted. All existing auth and hot-reload tests pass unchanged.
