179 lines
6.9 KiB
Markdown
179 lines
6.9 KiB
Markdown
# M3.5.10 — Auth integration with Authentik/Vault
|
||||
|
|
|
|||
|
|
| Field | Value |
|
|||
|
|
|---|---|
|
|||
|
|
| Phase | M3.5 — Distributed API Layer |
|
|||
|
|
| Size | M — 1–3 days |
|
|||
|
|
| Status | ⬜ Not started |
|
|||
|
|
| Flags | — |
|
|||
|
|
| Spec | inlined below |
|
|||
|
|
| Blocks | — |
|
|||
|
|
| Depends | M3.5.1 |
|
|||
|
|
|
|||
|
|
## Goal
|
|||
|
|
|
|||
|
|
Replace the placeholder `apikey` header check with proper authentication via the
|
|||
|
|
cluster's IAM stack: **Authentik** (OIDC provider) → **HashiCorp Vault** (token
|
|||
|
|
issuer) → **memory service** (token validator).
|
|||
|
|
|
|||
|
|
## Facts (inlined — no spec read needed)
|
|||
|
|
|
|||
|
|
**Current (wrong):**
|
|||
|
|
```rust
|
|||
|
|
fn check_auth(req: &HttpRequest, state: &AppState) -> Result<(), HttpResponse> {
|
|||
|
|
let api_key = req.headers().get("apikey").and_then(|h| h.to_str().ok());
|
|||
|
|
if api_key != Some(&state.api_key) {
|
|||
|
|
return Err(HttpResponse::Unauthorized().json(...));
|
|||
|
|
}
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
This is a raw string match against `MEM_API_KEY` env var. No JWT, no Vault, no
|
|||
|
|
user identity. It does not integrate with the cluster's IAM stack.
|
|||
|
|
|
|||
|
|
**Cluster IAM stack:**
|
|||
|
|
- **Authentik** (`iam` namespace) — OIDC provider at
|
|||
|
|
`https://authentik.riotpiao.com/application/o/vault/`
|
|||
|
|
- **HashiCorp Vault** (`iam` namespace) — OIDC auth method enabled, validates
|
|||
|
|
Authentik JWTs, issues Vault tokens based on role/policy.
|
|||
|
|
- **Vault OIDC role:** `auth/oidc/role/homelab-admin`
|
|||
|
|
- `bound_claims: { "permissions": "*" }`
|
|||
|
|
- Policy: `homelab-admin` (path `"*"` full access)
|
|||
|
|
- **Vault unseal:** Shamir 3/3, keys in `vault-unseal-keys` secret, S3 backend
|
|||
|
|
via MinIO.
|
|||
|
|
|
|||
|
|
**Auth flow (production):**
|
|||
|
|
```
|
|||
|
|
User/Agent authenticates with Authentik (OIDC)
|
|||
|
|
→ Receives JWT with claims { sub, permissions, groups, ... }
|
|||
|
|
→ Presents JWT to Vault OIDC auth method
|
|||
|
|
→ Vault validates JWT against Authentik JWKS
|
|||
|
|
→ Vault issues Vault token with matched policy
|
|||
|
|
→ Client sends Vault token to memory service
|
|||
|
|
→ Memory service validates token via Vault API
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Three integration options (pick one):**
|
|||
|
|
|
|||
|
|
### Option A: Vault token validation (recommended)
|
|||
|
|
Memory service receives `X-Vault-Token` header, calls Vault's
|
|||
|
|
`POST /v1/auth/token/lookup-self` to validate. Extracts policy and metadata.
|
|||
|
|
- Pro: Vault is the single source of truth for authorization.
|
|||
|
|
- Pro: Token revocation is immediate (Vault controls lifecycle).
|
|||
|
|
- Con: Extra network call per request (cache with TTL to mitigate).
|
|||
|
|
|
|||
|
|
### Option B: Direct JWKS validation
|
|||
|
|
Memory service fetches Authentik's JWKS endpoint, validates JWT `Authorization:
|
|||
|
|
Bearer <token>` directly. No Vault in the request path.
|
|||
|
|
- Pro: No Vault dependency at request time.
|
|||
|
|
- Pro: Standard OAuth2/OIDC pattern.
|
|||
|
|
- Con: Token revocation is delayed (until JWT expires).
|
|||
|
|
- Con: Memory service must know about Authentik's OIDC config.
|
|||
|
|
|
|||
|
|
### Option C: Trust gateway
|
|||
|
|
Memory service trusts homelab-frontend gateway (cluster-internal traffic).
|
|||
|
|
Gateway validates auth, forwards `X-User-Id` and `X-Capabilities` headers.
|
|||
|
|
Memory service checks capabilities against ServiceAdapter CRD requirements.
|
|||
|
|
- Pro: Auth logic centralized in gateway.
|
|||
|
|
- Pro: Memory service stays simple.
|
|||
|
|
- Con: Gateway auth is currently a stub (`hasCapability()` returns true for any
|
|||
|
|
`Authorization` header).
|
|||
|
|
- Con: Requires gateway auth to be completed first (homelab-frontend task 8.3).
|
|||
|
|
|
|||
|
|
**ServiceAdapter CRD for memory (`memory-adapter`):**
|
|||
|
|
```yaml
|
|||
|
|
auth:
|
|||
|
|
capability: memory:read # default
|
|||
|
|
required: true
|
|||
|
|
resources:
|
|||
|
|
- name: ingest
|
|||
|
|
methods:
|
|||
|
|
- verb: POST
|
|||
|
|
auth: { capability: memory:write, required: true }
|
|||
|
|
- name: query
|
|||
|
|
methods:
|
|||
|
|
- verb: POST
|
|||
|
|
- name: skills
|
|||
|
|
methods:
|
|||
|
|
- verb: GET
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Capabilities needed:**
|
|||
|
|
- `memory:read` — query, skills, vault browse, projects, sources
|
|||
|
|
- `memory:write` — ingest, source sync, skill draft
|
|||
|
|
|
|||
|
|
## Steps
|
|||
|
|
|
|||
|
|
### Option A (Vault token — recommended)
|
|||
|
|
|
|||
|
|
1. Add `vault_addr` to `AppState` (default: `http://vault.iam.svc.cluster.local:8200`).
|
|||
|
|
2. Replace `check_auth()` with `validate_vault_token()`:
|
|||
|
|
```rust
|
|||
|
|
async fn validate_vault_token(req: &HttpRequest, state: &AppState) -> Result<VaultIdentity, HttpResponse> {
|
|||
|
|
let token = req.headers().get("X-Vault-Token")
|
|||
|
|
.or_else(|| req.headers().get("Authorization")) // Bearer <token>
|
|||
|
|
.and_then(|h| h.to_str().ok());
|
|||
|
|
// POST vault_addr/v1/auth/token/lookup-self with X-Vault-Token header
|
|||
|
|
// Parse response: policies, metadata, ttl
|
|||
|
|
// Cache token -> identity for TTL duration
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
3. Add token cache (HashMap<token_hash, (VaultIdentity, Instant)>) with configurable TTL.
|
|||
|
|
4. Extract `VaultIdentity` (policies, metadata) from lookup response.
|
|||
|
|
5. Map policies to capabilities: `homelab-admin` → `memory:read` + `memory:write`.
|
|||
|
|
6. Update each handler to check required capability.
|
|||
|
|
7. Keep `apikey` as fallback for dev/test (controlled by env var `MEM_AUTH_MODE=vault|apikey`).
|
|||
|
|
|
|||
|
|
### For all options
|
|||
|
|
|
|||
|
|
8. Add env vars: `VAULT_ADDR`, `MEM_AUTH_MODE` (vault/jwks/gateway/apikey).
|
|||
|
|
9. Update K8s deployment to inject `VAULT_ADDR`.
|
|||
|
|
10. Update ServiceAdapter CRD if needed.
|
|||
|
|
11. Document auth flow in README.
|
|||
|
|
|
|||
|
|
## Acceptance
|
|||
|
|
|
|||
|
|
- Requests with valid Vault token are accepted.
|
|||
|
|
- Requests with expired/revoked Vault token are rejected (401).
|
|||
|
|
- Requests without any auth are rejected (401).
|
|||
|
|
- `memory:write` capability required for ingest/sync endpoints.
|
|||
|
|
- `memory:read` capability sufficient for query/skills/vault endpoints.
|
|||
|
|
- Token cache reduces Vault API calls on repeated requests.
|
|||
|
|
- Fallback to `apikey` mode for dev/test environments.
|
|||
|
|
|
|||
|
|
## Verify
|
|||
|
|
|
|||
|
|
**Integration test** — `tests/it_auth_integration.rs`:
|
|||
|
|
1. `a1_vault_token_accepted` — mock Vault lookup-self returning valid response;
|
|||
|
|
assert request proceeds.
|
|||
|
|
2. `a2_expired_token_rejected` — mock Vault returning 403; assert 401 response.
|
|||
|
|
3. `a3_no_auth_rejected` — request with no auth headers; assert 401.
|
|||
|
|
4. `a4_write_requires_capability` — token with `memory:read` only; POST /ingest;
|
|||
|
|
assert 403.
|
|||
|
|
5. `a5_read_with_read_capability` — token with `memory:read`; GET /query;
|
|||
|
|
assert proceeds.
|
|||
|
|
6. `a6_token_cache_hit` — same token twice; assert Vault called once.
|
|||
|
|
7. `a7_apikey_fallback` — `MEM_AUTH_MODE=apikey`; assert old behavior works.
|
|||
|
|
8. `a8_auth_mode_configurable` — assert `MEM_AUTH_MODE` switches validation logic.
|
|||
|
|
|
|||
|
|
**Command:** `cargo test --test it_auth_integration`
|
|||
|
|
|
|||
|
|
**False pass:**
|
|||
|
|
- Testing only with apikey fallback. The Vault integration is the whole point.
|
|||
|
|
- Mocking Vault without testing cache expiry. A cache that never expires accepts
|
|||
|
|
revoked tokens forever.
|
|||
|
|
|
|||
|
|
## Traps
|
|||
|
|
|
|||
|
|
- Calling Vault on every request without caching. Vault API calls add 5-10ms
|
|||
|
|
per request. Cache with TTL matching token TTL (or shorter).
|
|||
|
|
- Not handling Vault being temporarily unreachable. Return 503 (not 401) if
|
|||
|
|
Vault is down — "cannot verify" is not "unauthorized".
|
|||
|
|
- Hardcoding Vault addr. Use env var + service discovery.
|
|||
|
|
- Not supporting `Authorization: Bearer <token>` format alongside `X-Vault-Token`.
|
|||
|
|
Different clients use different conventions.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
Background: [DESIGN.md](../DESIGN.md) — auth section, Authentik/Vault IAM stack
|