# Axiom Security Spec

**Status:** Draft
**Owner:** Ben Booth
**Created:** 2026-03-18
**Last Updated:** 2026-03-18
**PRD:** [Security PRD](../requirements/prd-security.md)

**Scope:** Identity (Ory Kratos), AuthN (OAuth/LDAP/SAML/OIDC/local), AuthZ
(OpenFGA — RBAC + ReBAC + ABAC), Credential & Key Management (Keychain/Vault),
Export Control Defense (8-layer), Audit Logging (PostgreSQL + HMAC)

---

## 1. Architecture Overview

Three systems, one design surface:

```
┌─────────────────────────────────────────────────────────┐
│                    axiom CLI / agents                     │
│                                                         │
│  axiom login ─────→ Ory Kratos (identity)                │
│                       ↓ post-login webhook              │
│                    Axiom hook-service                │
│                       ↓ write tuples                    │
│                    OpenFGA (authorization)               │
│                       ↑ check(user, resource, relation) │
│  axiom chat ──→ Gateway ──→ OpenFGA gate ──→ LLM        │
│                                                         │
│  get_credential() ──→ Credential Provider Stack         │
│                       ├─ OS Keychain                    │
│                       ├─ Vault                          │
│                       └─ File (fallback)                │
└─────────────────────────────────────────────────────────┘
```

**Services (all managed by ServiceManager):**

| Service | Binary | Port | Storage | Purpose |
|---------|--------|------|---------|---------|
| Ory Kratos | `kratos` | 4433 (public), 4434 (admin) | PostgreSQL | Identity + authentication |
| OpenFGA | `openfga` | 8080 (HTTP), 8081 (gRPC) | PostgreSQL | Authorization |
| PostgreSQL | via K3D | 5432 | Disk | Shared backend |

---

## 2. Identity: Ory Kratos

### 2.1 Deployment

Kratos runs as a managed service via `ServiceManager`:

```
axiom connect kratos
    → brew install ory/tap/kratos (macOS)
    → ServiceManager registers com.axiom.kratos
    → Kratos schema migrated: kratos migrate sql postgres://...
    → Config generated: ~/.axi/services/kratos.yml
    → Service started (RunAtLoad, KeepAlive)
```

### 2.2 Kratos Configuration

Generated by `setup_kratos()` based on `auth.toml`:

```yaml
# ~/.axi/services/kratos.yml (generated, not hand-edited)
version: v1.2.0

dsn: postgres://axiom:axiom@localhost:5432/axiom_kratos?sslmode=disable

serve:
  public:
    base_url: http://localhost:4433/
    cors:
      enabled: true
  admin:
    base_url: http://localhost:4434/

selfservice:
  default_browser_return_url: http://localhost:19821/
  flows:
    login:
      ui_url: http://localhost:19821/login
      after:
        hooks:
          - hook: web_hook
            config:
              url: http://localhost:19822/hooks/post-login
              method: POST
              body: base64://...  # JSON template
    registration:
      ui_url: http://localhost:19821/register
      after:
        hooks:
          - hook: web_hook
            config:
              url: http://localhost:19822/hooks/post-registration
              method: POST
              body: base64://...

identity:
  default_schema_id: default
  schemas:
    - id: default
      url: file:///~/.axi/services/identity.schema.json

# OAuth / social sign-in (populated from auth.toml)
selfservice:
  methods:
    oidc:
      enabled: true
      config:
        providers: []  # Populated dynamically from auth.toml
    password:
      enabled: true
    totp:
      enabled: true
    webauthn:
      enabled: true
```

### 2.3 Identity Schema

```json
{
  "$id": "https://axiom.dev/identity.schema.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Axiom Identity",
  "type": "object",
  "properties": {
    "traits": {
      "type": "object",
      "properties": {
        "email": {
          "type": "string",
          "format": "email",
          "ory.sh/kratos": {
            "credentials": { "password": { "identifier": true } },
            "verification": { "via": "email" },
            "recovery": { "via": "email" }
          }
        },
        "name": {
          "type": "object",
          "properties": {
            "first": { "type": "string" },
            "last": { "type": "string" }
          }
        },
        "organization": { "type": "string" },
        "facility": { "type": "string" }
      },
      "required": ["email"]
    }
  }
}
```

### 2.4 OAuth Provider Configuration

OAuth providers are configured in Kratos via `auth.toml`. The `setup_kratos()`
hook reads `auth.toml` and generates the Kratos OIDC provider config:

```python
# Pseudo-code for setup_kratos()
def _build_oidc_providers(auth_config: dict) -> list[dict]:
    providers = []
    for name, cfg in auth_config.get("oauth", {}).items():
        if not cfg.get("enabled") or not cfg.get("client_id"):
            continue
        providers.append({
            "id": name,
            "provider": _provider_type(name),  # "google" | "microsoft" | "github" | "generic"
            "client_id": cfg["client_id"],
            "client_secret": get_credential(f"oauth_{name}_secret"),
            "mapper_url": f"file:///~/.axi/services/mappers/{name}.jsonnet",
            "scope": cfg.get("scopes", ["openid", "profile", "email"]),
        })
    return providers
```

### 2.5 CLI Auth Flow

```
axiom login
    ↓
1. Check if Kratos is running (ensure_available("kratos"))
2. Fetch available login methods from Kratos API
3. Present choices to user (OAuth providers + local)
4. User selects provider:
   a. OAuth → open browser to Kratos /self-service/login?provider=google
       → Google authenticates → redirect to localhost:19821/callback
       → Kratos creates session → returns session token
   b. Local → prompt email + password → POST to Kratos login flow
       → Kratos verifies → returns session token
5. Store session token at ~/.axi/session.json (0600)
6. Post-login webhook fires → OpenFGA tuples written
7. Print: "✓ Logged in as ben@utexas.edu"
```

---

## 3. Authorization: OpenFGA

### 3.1 Deployment

```
axiom connect openfga
    → brew install openfga/tap/openfga (macOS)
    → ServiceManager registers com.axiom.openfga
    → OpenFGA migrations: openfga migrate --datastore-engine postgres
    → Auth model loaded: openfga model write
    → Service started
```

### 3.2 Authorization Model

```dsl
model
  schema 1.1

type user

type role
  relations
    define member: [user]

type connection
  relations
    define can_access: [user, role#member]
    define admin: [user, role#member]

type rag_corpus
  relations
    define can_query: [user, role#member]
    define can_index: [user, role#member]

type document
  relations
    define owner: [user]
    define can_read: owner or can_query from parent_corpus
    define can_write: owner or admin from parent_connection
    define parent_corpus: [rag_corpus]
    define parent_connection: [connection]
```

### 3.3 Predefined Roles

| Role | Tuples Written | Grants |
|------|---------------|--------|
| `public_access` | `user:X → role:public_access#member` | Public tier only |
| `export_controlled_access` | `user:X → role:ec_access#member` + `role:ec_access → connection:tacc-qwen#can_access` | EC tier + VPN providers |
| `admin` | `user:X → role:admin#member` + `role:admin → connection:*#admin` | All connections + config |
| `compliance_officer` | `user:X → role:compliance#member` | Read-only audit access |

### 3.4 Webhook Bridge (Kratos → OpenFGA)

Axiom runs a lightweight hook-service that receives Kratos webhooks
and writes OpenFGA tuples:

```python
# src/axiom/infra/auth/webhook.py

async def post_login_hook(identity: dict) -> None:
    """Called by Kratos after successful login."""
    user_id = identity["traits"]["email"]
    org = identity["traits"].get("organization", "")

    # Read role mapping from auth.toml
    role_mapping = load_role_mapping()

    # Extract groups from IdP claims (if OAuth login)
    idp_groups = identity.get("metadata_public", {}).get("groups", [])

    # Write base tuple (user exists)
    openfga.write(user=user_id, relation="member", object="role:public_access")

    # Map IdP groups to Axiom roles
    for idp_group, axi_role in role_mapping.items():
        if idp_group in idp_groups:
            openfga.write(user=user_id, relation="member", object=f"role:{axi_role}")

    # First-user bootstrap: if no admin exists, make this user admin
    if not openfga.has_any("role:admin#member"):
        openfga.write(user=user_id, relation="member", object="role:admin")
```

### 3.5 Authorization Check in Gateway

```python
# In gateway._select_provider():

def _select_provider(self, tier, session):
    provider = self._find_provider(tier)

    # Authorization gate (if OpenFGA is running)
    if self._openfga_available():
        allowed = openfga.check(
            user=f"user:{session.user_id}",
            relation="can_access",
            object=f"connection:{provider.name}",
        )
        if not allowed:
            return GatewayResponse(
                text=f"Access denied: your role does not allow {provider.name}. "
                     f"Contact your administrator.",
                success=False,
                provider="auth_gate",
            )

    return self._call_provider(provider, ...)
```

---

## 4. Credential Providers

### 4.1 Provider Implementations

```python
class CredentialProvider(ABC):
    @abstractmethod
    def name(self) -> str: ...
    @abstractmethod
    def available(self) -> bool: ...
    @abstractmethod
    def get(self, service: str) -> str | None: ...
    @abstractmethod
    def store(self, service: str, value: str, metadata: CredentialMetadata | None = None) -> bool: ...
    @abstractmethod
    def delete(self, service: str) -> bool: ...
```

### 4.2 macOS: KeychainProvider

```python
class KeychainProvider(CredentialProvider):
    SERVICE_PREFIX = "com.axiom"

    def get(self, service):
        result = subprocess.run([
            "security", "find-generic-password",
            "-s", f"{self.SERVICE_PREFIX}.{service}",
            "-a", "axiom", "-w",
        ], capture_output=True, text=True)
        return result.stdout.strip() if result.returncode == 0 else None

    def store(self, service, value, metadata=None):
        self.delete(service)  # Remove existing
        comment = json.dumps(metadata.to_dict()) if metadata else ""
        subprocess.run([
            "security", "add-generic-password",
            "-s", f"{self.SERVICE_PREFIX}.{service}",
            "-a", "axiom", "-w", value, "-j", comment, "-U",
        ], capture_output=True)
        return True
```

### 4.3 Linux: SecretServiceProvider

```python
class SecretServiceProvider(CredentialProvider):
    def get(self, service):
        result = subprocess.run([
            "secret-tool", "lookup",
            "application", "axiom", "connection", service,
        ], capture_output=True, text=True)
        return result.stdout.strip() if result.returncode == 0 else None
```

### 4.4 VaultProvider

```python
class VaultProvider(CredentialProvider):
    def available(self):
        return bool(os.environ.get("VAULT_ADDR"))

    def get(self, service):
        resp = requests.get(
            f"{self._addr}/v1/secret/data/axiom/{service}",
            headers={"X-Vault-Token": self._token}, timeout=5,
        )
        return resp.json()["data"]["data"].get("token") if resp.ok else None
```

### 4.5 Resolution Chain

```
get_credential("anthropic")
├─ 1. $ANTHROPIC_API_KEY           ← CI/CD, containers
├─ 2. Keychain/SecretService       ← dev machines (encrypted)
├─ 3. Vault                        ← production (if VAULT_ADDR set)
├─ 4. ~/.axi/credentials/ (0600)  ← fallback
└─ 5. None                         ← caller degrades gracefully
```

---

## 5. EC Defense Layers

### 5.1 Defense Architecture

```
Layer 1: Export control classification (keyword + Ollama SLM)
Layer 2: VPN network boundary (physical isolation)
Layer 3: Chunk sanitization (strip injection patterns before LLM)
Layer 4: System prompt hardening (non-negotiable security instructions)
Layer 5: Response scanning (classify response before network boundary)
Layer 6: Session suspension (kill session after N leakage events)
Layer 7: Store quarantine (isolate EC content in public RAG)
Layer 8: OpenFGA authorization gate (independent of classification)
```

### 5.2 Security Audit Log

```sql
CREATE TABLE security_events (
    id              BIGSERIAL PRIMARY KEY,
    event_type      TEXT NOT NULL,
    severity        TEXT NOT NULL,
    session_id      TEXT,
    user_id         TEXT,
    query_hash      TEXT,       -- SHA-256, not plaintext
    response_hash   TEXT,
    matched_terms   TEXT[],
    source_paths    TEXT[],
    provider        TEXT,
    routing_tier    TEXT,
    resolved        BOOLEAN DEFAULT FALSE,
    event_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    hmac            TEXT NOT NULL
);
```

---

## 6. Identity Extension

All identity infrastructure is packaged as a builtin extension:

```
src/axiom/extensions/builtins/identity/
    axiom-extension.toml          # Declares kratos + openfga connections
    __init__.py
    cli.py                       # axiom login / logout / whoami
    connections.py               # setup_kratos(), ensure_kratos_running(),
                                 # setup_openfga(), ensure_openfga_running()
    webhook.py                   # Kratos → OpenFGA hook-service
    session.py                   # Session management (~/.axi/session.json)
    tests/
```

### 6.1 Extension Manifest

```toml
[extension]
name = "identity"
version = "0.5.0"
description = "Identity, authentication, and authorization"
builtin = true
kind = "utility"
module = "platform"

[[cli.commands]]
noun = "login"
module = "axiom.extensions.builtins.identity.cli"
description = "Log in to Axiom"

[[cli.commands]]
noun = "logout"
module = "axiom.extensions.builtins.identity.cli"
description = "Log out of Axiom"

[[cli.commands]]
noun = "whoami"
module = "axiom.extensions.builtins.identity.cli"
description = "Show current identity and roles"

[[connections]]
name = "kratos"
display_name = "Ory Kratos (Identity)"
kind = "cli"
endpoint = "kratos"
credential_type = "none"
health_check = "http_get"
health_endpoint = "http://localhost:4433/health/alive"
category = "identity"
capabilities = ["read", "write"]
ensure_module = "axiom.extensions.builtins.identity.connections"
ensure_function = "ensure_kratos_running"
post_setup_module = "axiom.extensions.builtins.identity.connections"
post_setup_function = "setup_kratos"

[connections.install_commands]
macos = "brew install ory/tap/kratos"
linux = "bash <(curl https://raw.githubusercontent.com/ory/meta/master/install.sh) -b /usr/local/bin kratos"

[[connections]]
name = "openfga"
display_name = "OpenFGA (Authorization)"
kind = "cli"
endpoint = "openfga"
credential_type = "none"
health_check = "http_get"
health_endpoint = "http://localhost:8080/healthz"
category = "identity"
capabilities = ["read", "write"]
ensure_module = "axiom.extensions.builtins.identity.connections"
ensure_function = "ensure_openfga_running"
post_setup_module = "axiom.extensions.builtins.identity.connections"
post_setup_function = "setup_openfga"

[connections.install_commands]
macos = "brew install openfga/tap/openfga"
linux = "bash <(curl https://raw.githubusercontent.com/openfga/openfga/main/install.sh) -b /usr/local/bin openfga"
```

---

## 7. Implementation Plan

### Phase 2: OS Keychain + Credential Metadata (v0.5.0)

- KeychainProvider, SecretServiceProvider, WindowsCredentialProvider
- CredentialMetadata (saved_at, expires_at, last_verified)
- `axiom connect --migrate` from .env to Keychain
- M-O expiry watch

### Phase 3: Identity (Kratos + Local Auth) (v0.5.x)

- Deploy Kratos as managed service
- Identity extension with `axiom login` / `axiom logout` / `axiom whoami`
- Local registration + TOTP MFA
- Session management
- Identity in agent context + audit logs

### Phase 4: OAuth + SSO (v0.6.0)

- Google, Microsoft, GitHub, GitLab OAuth via Kratos
- LDAP/AD, generic OIDC, SAML via Kratos
- `auth.toml` configuration
- IdP claim → role mapping

### Phase 5: OpenFGA Authorization (v0.6.x)

- Deploy OpenFGA as managed service
- Kratos → OpenFGA webhook bridge
- Connection-level + document-level access control
- Gateway authorization gate
- `axiom admin grant/revoke` commands

### Phase 6: EC Defense Layers (v0.7.0)

- Chunk sanitization, response scanning, prompt hardening
- Security audit log (PostgreSQL + HMAC)
- `axiom doctor --security`
- Red-team test suite (promptfoo)

### Phase 7: Vault + Rotation (v0.7.x)

- VaultProvider for production
- `axiom connect --migrate --target vault`
- Automatic credential rotation

---

## Related Documents

- [Connections PRD](../requirements/prd-connections.md) — Connection abstraction
- [Connections Spec](spec-connections.md) — `get_credential()`, health checks
- [Agent Architecture Spec](spec-agent-architecture.md) — M-O, D-FIB, EVE
- [Model Routing Spec](spec-model-routing.md) — EC classification
- [Executive PRD](../requirements/prd-executive.md)
