Files
alknet/docs/architecture/identity.md
glm-5.1 e7941da04a docs: clarify phase boundaries — Phase 1 vs downstream concerns
The architecture specs were implying that StorageIdentityProvider, irpc
service implementations, and application services (agent, Docker, etc.)
already exist. This commit makes the phasing explicit:

- services.md: deployment topology now clearly labels 'Current (Phase 1)'
  vs 'Future (Phase 2+)', notes that application services are downstream
- identity.md: StorageIdentityProvider labeled 'Future — Phase 2+',
  clarifying alknet-storage doesn't exist yet
- storage.md: adds phase note that the crate hasn't been built yet,
  StorageIdentityProvider is a future impl
- ADR-028: ConfigAuthService is Phase 1 path, StorageAuthService is
  Phase 2+ contract
- call-protocol.md: Agent Service Pattern section explicitly framed as
  a downstream application concern, not a core requirement
2026-06-07 10:29:52 +00:00

7.5 KiB

status, last_updated
status last_updated
draft 2026-06-07

Identity

What

The Identity type and IdentityProvider trait are the core abstractions for authentication and authorization in alknet. Identity is the unified result of auth verification — whether via SSH public key, signed timestamp token, or database lookup. IdentityProvider is the trait that resolves credentials to an Identity, decoupling alknet-core from any specific identity storage.

Why

Auth, forwarding policy, and call protocol all need to know who is making a request and what they are authorized to do. Without Identity in core, each subsystem would define its own identity type, leading to duplication and conversion boilerplate. Without IdentityProvider as a trait, alknet-core would either hardcode config-file-based auth or take a database dependency — neither acceptable for a library crate.

The IdentityProvider trait exists because the same auth verification concept needs two implementations: ConfigIdentityProvider for minimal deployments (all keys in memory via ArcSwap) and StorageIdentityProvider for production (SQLite lookup via peer_credentials and ACL graph). The trait is the contract; the backing store is pluggable.

Architecture

Identity Struct

pub struct Identity {
    pub id: String,                               // Fingerprint or account UUID
    pub scopes: Vec<String>,                      // e.g., ["relay:connect", "service:gitea:read"]
    pub resources: HashMap<String, Vec<String>>,   // e.g., {"service": ["gitea", "registry"]}
}

The id field serves dual purpose:

  • Config-based auth (ConfigIdentityProvider): holds the Ed25519 key fingerprint (e.g., SHA256:abc123...)
  • Database-backed auth (StorageIdentityProvider): holds the account UUID from the accounts table

This keeps the type simple while accommodating both auth paths. Downstream consumers (forwarding policy, call protocol ACL checks) use scopes and resources without knowing whether the identity came from a config file or a database.

IdentityProvider Trait

pub trait IdentityProvider: Send + Sync + 'static {
    /// Resolve an SSH public key fingerprint to an identity.
    fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity>;

    /// Resolve an auth token to an identity.
    /// Returns None if the token is invalid, expired, or the key is not authorized.
    fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
}

Both SSH key auth and token auth resolve to the same Identity type. The trait lives in alknet_core::auth.

ConfigIdentityProvider (Default)

Reads from ArcSwap<DynamicConfig.auth> per ADR-030. Every authorized key gets a default scope set. No database dependency. This is the default for CLI and single-node deployments.

pub struct ConfigIdentityProvider {
    auth_config: Arc<ArcSwap<DynamicConfig>>,
}

impl IdentityProvider for ConfigIdentityProvider {
    fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
        let config = self.auth_config.load();
        config.auth.ssh.authorized_keys.get(fingerprint)
            .map(|key_entry| Identity {
                id: fingerprint.to_string(),
                scopes: key_entry.scopes.clone(),
                resources: key_entry.resources.clone(),
            })
    }

    fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
        // Verify Ed25519 signature against the same authorized_keys set
        // Resolve to the same Identity as SSH auth would produce
    }
}

StorageIdentityProvider (Future — Phase 2+)

Implemented in alknet-storage (a crate that doesn't exist yet). Backed by SQLite peer_credentials and api_keys tables plus the ACL graph. Resolves fingerprint → account → organization membership → effective scopes.

This implementation is defined here so the contract is clear, but alknet-storage hasn't been built yet. Phase 1 uses ConfigIdentityProvider exclusively. When alknet-storage is built, it implements alknet-core's IdentityProvider trait, and the CLI/NAPI assembly layer wires the concrete implementation.

AuthProtocol irpc Service

The AuthProtocol irpc service (behind the irpc feature flag per ADR-028) provides an async boundary for auth verification. It is one way to satisfy the IdentityProvider trait, not a replacement for it:

enum AuthProtocol {
    VerifyPubkey { fingerprint: String, key_data: Vec<u8> },
    VerifyToken { token_bytes: Vec<u8>, timestamp: u64 },
    ReloadKeys,
    CheckAccess { identity: Identity, operation: String },
}

enum AuthResult {
    Ok(Identity),
    Denied(String),
}

The relationship:

  • Trait-based path: Handler calls identity_provider.resolve_from_fingerprint() directly. Zero overhead. Used when irpc is disabled or when the implementation is local.
  • irpc path: Handler calls identity_provider.resolve_from_fingerprint(), which internally delegates to AuthProtocol::VerifyPubkey via an irpc client. Used in production deployments with SQLite-backed auth.

Both paths produce the same Identity result. Note: the irpc path requires the service layer to be built (Phase 2+). Phase 1 uses the trait path exclusively.

Auth Flows

SSH key auth (existing, unchanged):

Client connects → SSH handshake → auth_publickey() callback
  → IdentityProvider::resolve_from_fingerprint(fingerprint)
  → Some(Identity) or None

Token auth (new, for non-SSH transports):

Browser connects → WebTransport CONNECT request
  → Extract token from URL path or Authorization header
  → IdentityProvider::resolve_from_token(token)
  → Some(Identity) or None

Both paths produce an Identity. The Identity is attached to the connection and used by ForwardingPolicy and call protocol for authorization decisions.

Constraints

  • Identity and IdentityProvider live in alknet_core::auth. No database dependency at the core level (ADR-029).
  • alknet-storage implements the core trait — the dependency goes from storage to core, not the other way.
  • The id field in Identity serves dual purpose (fingerprint or UUID). This is a deliberate simplification — downstream consumers don't need to know the source.
  • Certificate authority tokens are not supported for token auth in v1 (ADR-023).
  • The irpc feature flag means nodes that only do SSH tunneling don't need the service layer overhead.

Open Questions

  • None specific to this spec. See open-questions.md for general auth questions (OQ-15, OQ-19).

Design Decisions

ADR Decision Summary
029 Identity as core type Identity and IdentityProvider live in alknet-core, not storage
028 Auth as irpc service AuthProtocol behind feature flag; IdentityProvider is the contract
023 Unified auth Same key material for SSH and token auth; same Identity result

References