+ API access
+
+ API tokens
+
+
+ A token is a single bearer credential for a bot or strategy. It belongs to a
+ principal; what it can trade follows that principal's portfolio grants. Point
+ clients at your OMS host and send{" "}
+ Authorization: Bearer <token>.
+
+
+
+ Sign in
+
+
+ Enter your admin password to open the console.
+
+
+
+
+
+
+ Set by OMS_ADMIN_PASSWORD
+
+
+
+
+ );
+}
diff --git a/readme.md b/readme.md
index e1cd4a3..621ba67 100644
--- a/readme.md
+++ b/readme.md
@@ -43,6 +43,20 @@ skip auto-sync, or `OMS_BOOTSTRAP=off` when infrastructure is provisioned elsewh
The SPY fixture seeds `alpaca-paper` + a test user (`test-trader-key` : `test-secret`),
so you can place a paper order immediately. Admin webapp: `cd cockpit && npm install && npm run dev`.
+## Auth
+
+- **Cockpit login** — the console is gated by a single password: `OMS_ADMIN_TOKEN`
+ (enabled via `OMS_ADMIN_AUTH_ENABLED=true`). Enter it on the login screen; it's sent
+ as a bearer to `/admin`. Set a strong random value for any real deployment.
+- **Trading tokens** — generate one on the cockpit's *Trading tokens* page (or
+ `POST /admin/trading-tokens`). A token belongs to a **principal** (a trader /
+ strategy / service) and is a single copy-once bearer string used by API clients as
+ `Authorization: Bearer `. What it can trade comes from the principal's
+ portfolio grants (`can_trade`), so you can mint several tokens under one principal
+ to rotate credentials without re-permissioning. Revoke any token anytime.
+- The legacy HTTP Basic form (`key_id:secret`, e.g. the `test-trader` dev user) still
+ works on the trading routes.
+
### Manual setup (optional)
The bootstrap just orchestrates the same idempotent make targets, if you'd rather run
diff --git a/src/admin.rs b/src/admin.rs
index 8676d82..a1590c2 100644
--- a/src/admin.rs
+++ b/src/admin.rs
@@ -803,26 +803,8 @@ pub async fn register_principal_key(
Path(principal_id): Path,
Json(payload): Json,
) -> Result, AdminError> {
- let key_id = format!("ak_{}", Uuid::new_v4().simple());
-
- let mut raw = [0u8; 32];
- raw[..16].copy_from_slice(Uuid::new_v4().as_bytes());
- raw[16..].copy_from_slice(Uuid::new_v4().as_bytes());
- let plaintext_secret = format!("sk_{}", general_purpose::URL_SAFE_NO_PAD.encode(raw));
-
- info!(principal_id = %principal_id, key_id = %key_id, "admin register key");
-
- let secret_to_hash = plaintext_secret.clone();
- let secret_hash = tokio::task::spawn_blocking(move || bcrypt::hash(&secret_to_hash, 12))
- .await
- .map_err(|_| AdminError {
- status: StatusCode::INTERNAL_SERVER_ERROR,
- message: "hash task failed".to_string(),
- })?
- .map_err(|_| AdminError {
- status: StatusCode::INTERNAL_SERVER_ERROR,
- message: "failed to hash secret".to_string(),
- })?;
+ info!(principal_id = %principal_id, "admin register key");
+ let KeyMaterial { key_id, plaintext_secret, secret_hash } = generate_key_material().await?;
let mut record = sqlx::query_as::<_, ApiKeyRecord>(
r#"
@@ -880,6 +862,173 @@ pub async fn revoke_principal_key(
Ok(StatusCode::NO_CONTENT)
}
+// ── Trading tokens ────────────────────────────────────────────────────────────
+//
+// A "trading token" is an ordinary `api_key` presented to the trading routes as a
+// single bearer string `"{key_id}.{secret}"` (see `auth::extract_trading_credentials`).
+// The endpoints here make it a one-click, ready-to-trade credential: generating one
+// can auto-provision the principal + portfolio + `can_trade` grant it needs.
+
+/// Freshly generated key material, before it's persisted.
+struct KeyMaterial {
+ key_id: String,
+ plaintext_secret: String,
+ secret_hash: String,
+}
+
+/// Generate a new `(key_id, secret)` and its bcrypt hash. `key_id` = `ak_`,
+/// secret = `sk_`. Hashing runs off the async pool.
+async fn generate_key_material() -> Result {
+ let key_id = format!("ak_{}", Uuid::new_v4().simple());
+ let mut raw = [0u8; 32];
+ raw[..16].copy_from_slice(Uuid::new_v4().as_bytes());
+ raw[16..].copy_from_slice(Uuid::new_v4().as_bytes());
+ let plaintext_secret = format!("sk_{}", general_purpose::URL_SAFE_NO_PAD.encode(raw));
+
+ let to_hash = plaintext_secret.clone();
+ let secret_hash = tokio::task::spawn_blocking(move || bcrypt::hash(&to_hash, 12))
+ .await
+ .map_err(|_| AdminError { status: StatusCode::INTERNAL_SERVER_ERROR, message: "hash task failed".to_string() })?
+ .map_err(|_| AdminError { status: StatusCode::INTERNAL_SERVER_ERROR, message: "failed to hash secret".to_string() })?;
+
+ Ok(KeyMaterial { key_id, plaintext_secret, secret_hash })
+}
+
+#[derive(Debug, Deserialize, utoipa::ToSchema)]
+pub struct CreateTradingToken {
+ /// Mint the token under this existing principal (the trader/strategy/service it
+ /// belongs to). Create principals on the Principals admin surface first.
+ pub principal_id: Uuid,
+ /// Optionally entitle the principal to trade this portfolio (adds a `can_trade`
+ /// grant). Omit if the principal is already granted, or to grant separately.
+ pub portfolio_id: Option,
+ /// Human label for this key (shown in the token list). Optional.
+ pub label: Option,
+}
+
+#[derive(Debug, Serialize, utoipa::ToSchema)]
+pub struct TradingTokenCreated {
+ /// The single bearer token — `Authorization: Bearer `. Shown once.
+ pub token: String,
+ pub key_id: String,
+ pub principal_id: Uuid,
+ pub portfolio_id: Option,
+ pub label: Option,
+}
+
+#[derive(Debug, Serialize, sqlx::FromRow, utoipa::ToSchema)]
+pub struct TradingTokenRow {
+ pub key_id: String,
+ pub label: Option,
+ pub principal_id: Uuid,
+ pub principal_code: String,
+ pub principal_name: Option,
+ pub created_at: DateTime,
+}
+
+#[utoipa::path(
+ post, path = "/admin/trading-tokens", tag = "admin",
+ request_body = CreateTradingToken,
+ responses(
+ (status = 200, description = "Created — single bearer token included once", body = TradingTokenCreated),
+ (status = 422, description = "Auto-provision needs an active broker connection"),
+ ),
+ security(("bearer_token" = []))
+)]
+pub async fn create_trading_token(
+ State(state): State,
+ Json(payload): Json,
+) -> Result, AdminError> {
+ let label = payload.label.clone();
+ let principal_id = payload.principal_id;
+ info!(%principal_id, portfolio_id = ?payload.portfolio_id, "admin create trading token");
+
+ let material = generate_key_material().await?;
+ let mut tx = state.pool().begin().await.map_err(map_db_error)?;
+
+ // Optionally entitle the principal to trade a portfolio. (The token belongs to
+ // this principal; its grants decide what the token can trade.)
+ if let Some(portfolio_id) = payload.portfolio_id {
+ sqlx::query(
+ "INSERT INTO principal_portfolio_grant \
+ (id, principal_id, portfolio_id, can_trade, can_view, can_allocate) \
+ VALUES ($1, $2, $3, true, true, false) \
+ ON CONFLICT (principal_id, portfolio_id) DO NOTHING",
+ )
+ .bind(Uuid::new_v4())
+ .bind(principal_id)
+ .bind(portfolio_id)
+ .execute(&mut *tx)
+ .await
+ .map_err(map_db_error)?;
+ }
+
+ // Persist the api key under the principal.
+ sqlx::query("INSERT INTO api_key (principal_id, key_id, secret_hash, name) VALUES ($1, $2, $3, $4)")
+ .bind(principal_id)
+ .bind(&material.key_id)
+ .bind(&material.secret_hash)
+ .bind(label.clone())
+ .execute(&mut *tx)
+ .await
+ .map_err(map_db_error)?;
+
+ tx.commit().await.map_err(map_db_error)?;
+
+ Ok(Json(TradingTokenCreated {
+ token: format!("{}.{}", material.key_id, material.plaintext_secret),
+ key_id: material.key_id,
+ principal_id,
+ portfolio_id: payload.portfolio_id,
+ label,
+ }))
+}
+
+#[utoipa::path(
+ get, path = "/admin/trading-tokens", tag = "admin",
+ responses((status = 200, description = "Active trading tokens", body = [TradingTokenRow])),
+ security(("bearer_token" = []))
+)]
+pub async fn list_trading_tokens(
+ State(state): State,
+) -> Result>, AdminError> {
+ // Every api key is a trading credential (auth_middleware accepts it); list them
+ // with the principal they belong to.
+ let rows = sqlx::query_as::<_, TradingTokenRow>(
+ "SELECT k.key_id, k.name AS label, k.principal_id, \
+ p.code AS principal_code, p.display_name AS principal_name, k.created_at \
+ FROM api_key k JOIN principal p ON p.id = k.principal_id \
+ WHERE k.revoked_at IS NULL \
+ ORDER BY k.created_at DESC",
+ )
+ .fetch_all(state.pool())
+ .await
+ .map_err(map_db_error)?;
+ Ok(Json(rows))
+}
+
+#[utoipa::path(
+ delete, path = "/admin/trading-tokens/{key_id}", tag = "admin",
+ params(("key_id" = String, Path, description = "Token key id")),
+ responses((status = 204, description = "Revoked"), (status = 404, description = "Not found")),
+ security(("bearer_token" = []))
+)]
+pub async fn revoke_trading_token(
+ State(state): State,
+ Path(key_id): Path,
+) -> Result {
+ info!(key_id = %key_id, "admin revoke trading token");
+ let result = sqlx::query("UPDATE api_key SET revoked_at = now() WHERE key_id = $1 AND revoked_at IS NULL")
+ .bind(&key_id)
+ .execute(state.pool())
+ .await
+ .map_err(map_db_error)?;
+ if result.rows_affected() == 0 {
+ return Err(AdminError::not_found("token"));
+ }
+ Ok(StatusCode::NO_CONTENT)
+}
+
// ── Grant management ──────────────────────────────────────────────────────────
#[derive(Debug, Deserialize, utoipa::ToSchema)]
diff --git a/src/auth.rs b/src/auth.rs
index 2d5056e..2622e87 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -15,15 +15,39 @@ pub struct AuthContext {
pub principal_id: Uuid,
}
-// validate oms token pair
+/// Authenticate a trading request and inject `AuthContext { principal_id }`.
+///
+/// Accepts two equivalent credential forms carrying the same `(key_id, secret)`:
+/// - HTTP **Basic** `key_id:secret` — the original form (kept for back-compat, incl.
+/// the dev fixture and any existing callers).
+/// - **Bearer** `key_id.secret` — a single copy-paste "trading token" (Databento
+/// style). Split on the first `.` (neither `ak_…` key ids nor `sk_…` secrets
+/// contain a dot).
+///
+/// Both resolve through the same DB lookup + bcrypt verify ([`verify_key`]).
pub async fn auth_middleware(
State(state): State,
mut req: Request,
next: Next,
) -> Result {
- let (key_id, secret) = extract_basic_credentials(req.headers())?;
-
- // db lookup for secret of principal
+ let (key_id, secret) = extract_trading_credentials(req.headers())?;
+
+ let principal_id = verify_key(state.pool(), &key_id, &secret)
+ .await?
+ .ok_or_else(unauthorized)?;
+
+ req.extensions_mut().insert(AuthContext { principal_id });
+ Ok(next.run(req).await)
+}
+
+/// Look up an active api key by `key_id` and bcrypt-verify `secret`. Returns the
+/// owning `principal_id` on success, `None` if the key is unknown/revoked or the
+/// secret doesn't match. `Err` only for infrastructure failures (DB / task join).
+pub async fn verify_key(
+ pool: &sqlx::PgPool,
+ key_id: &str,
+ secret: &str,
+) -> Result