Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/registry-proxy-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
# NOT called here: that guard pins a local gcloud config + operator account,
# whereas CI authenticates keylessly as the deploy SA via WIF below.
#
# Required repository secrets — DEDICATED to the registry (dambi-registry),
# Required repository secrets — DEDICATED to the registry (project-c2aefc18-2bfc-495a-a3d),
# DISTINCT from policy-server-deploy.yml's GCP_* secrets (your-gcp-project-id).
# Shared with registry-publish.yml:
# REGISTRY_WIF_PROVIDER - full Workload Identity *provider* resource name (dambi-registry)
# REGISTRY_DEPLOY_SA - deploy SA email. Needs, on PROJECT_ID=dambi-registry:
# REGISTRY_WIF_PROVIDER - full Workload Identity *provider* resource name (project-c2aefc18-2bfc-495a-a3d)
# REGISTRY_DEPLOY_SA - deploy SA email. Needs, on PROJECT_ID=project-c2aefc18-2bfc-495a-a3d:
# roles/run.admin, roles/cloudbuild.builds.editor,
# roles/artifactregistry.writer, and
# roles/iam.serviceAccountUser on the runtime SA
Expand All @@ -46,7 +46,7 @@ jobs:
# avoids GCS entirely (docker push, see below), but `gcloud run deploy` still
# needs a billing project for its API calls — gcloud's modern clients read this
# and attach x-goog-user-project (SA holds serviceUsageConsumer there).
CLOUDSDK_BILLING_QUOTA_PROJECT: dambi-registry
CLOUDSDK_BILLING_QUOTA_PROJECT: project-c2aefc18-2bfc-495a-a3d
steps:
- uses: actions/checkout@v4

Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/registry-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
#
# Signing runs in KMS mode under keyless Workload Identity Federation — the
# private key never leaves the HSM, and no SA key file ever touches the runner.
# One-time WIF setup. These are DEDICATED registry secrets (dambi-registry),
# One-time WIF setup. These are DEDICATED registry secrets (project-c2aefc18-2bfc-495a-a3d),
# DISTINCT from policy-server-deploy.yml's GCP_* secrets (your-gcp-project-id) —
# the two target different projects/SAs, so they cannot share secret names.
#
# Required repository secrets:
# REGISTRY_WIF_PROVIDER - full Workload Identity *provider* resource name (dambi-registry)
# REGISTRY_WIF_PROVIDER - full Workload Identity *provider* resource name (project-c2aefc18-2bfc-495a-a3d)
# REGISTRY_DEPLOY_SA - signer/publisher SA email (needs roles/cloudkms.signerVerifier
# on the key + roles/storage.objectAdmin on the bucket)
#
Expand Down Expand Up @@ -41,9 +41,9 @@ on:
# CHANGE THESE for your own project.
# ----------------------------------------------------------------------------
env:
PROJECT_ID: dambi-registry
REGION: asia-northeast3
BUCKET: dambi-registry-v3-seoul
PROJECT_ID: project-c2aefc18-2bfc-495a-a3d
REGION: asia-northeast1
BUCKET: dambi-registry-v3-unseo
KMS_KEYRING: registry-signing
KMS_KEY: bundle-sign-p256
KMS_KEY_VERSION: "1"
Expand All @@ -53,7 +53,7 @@ env:
# project (gcloud reads this on every call) makes it attach x-goog-user-project
# to the storage rsync below. The local owner publish never hits this: user creds
# carry an implicit quota project.
CLOUDSDK_BILLING_QUOTA_PROJECT: dambi-registry
CLOUDSDK_BILLING_QUOTA_PROJECT: project-c2aefc18-2bfc-495a-a3d

permissions:
contents: read
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ AGENTS.md
!docs/security-invariants.md
!docs/sdk-migration/
!docs/sdk-migration/**
# Decision log — one-line ADRs so a settled call isn't re-litigated later.
!docs/decisions/
!docs/decisions/**
# Published package changelog must ship with the package and the repo.
!packages/core/CHANGELOG.md

Expand Down
38 changes: 38 additions & 0 deletions crates/policy-server/db/migrations/0013_api_keys_audit_events.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- Policy Hub: host API keys + client-reported audit events (POST /v1/audit).
--
-- v0.1 has no tenant/organization above a key (ADR 0001, 2026-09-12): one
-- key = one integrating host app. Adding a tenant later is an additive column
-- on api_keys, not a contract change.
--
-- api_keys stores only the SHA-256 of the presented key; the plaintext is
-- printed once by the `issue_api_key` binary and never persisted.
--
-- audit_events is an event log of what the CLIENT decided — the server never
-- re-runs Cedar. There is deliberately no wallet-address column (and the
-- handler rejects unknown fields), so a raw address cannot reach this table.
-- Dedup key is (api_key_id, event_id): event ids are client-generated, so two
-- hosts may legitimately collide on the same id.

CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY,
key_hash TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
created_at BIGINT NOT NULL,
revoked_at BIGINT
);

CREATE TABLE IF NOT EXISTS audit_events (
api_key_id UUID NOT NULL REFERENCES api_keys(id),
event_id TEXT NOT NULL,
request_digest TEXT NOT NULL,
verdict TEXT NOT NULL,
policy_version TEXT NOT NULL,
engine_version TEXT NOT NULL,
submitted_at BIGINT,
received_at BIGINT NOT NULL,
PRIMARY KEY (api_key_id, event_id),
CHECK (verdict IN ('allow', 'warn', 'deny'))
);

CREATE INDEX IF NOT EXISTS idx_audit_events_received_at
ON audit_events(received_at);
4 changes: 2 additions & 2 deletions crates/policy-server/db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ pub mod stores;

pub use error::{DbError, DbResult};
pub use stores::{
derive_user_id, market, GlobalDb, MultiUserStore, PostgresWalletMetadata, PostgresWalletStore,
TokenPriceFact, User,
audit, derive_user_id, market, GlobalDb, MultiUserStore, PostgresWalletMetadata,
PostgresWalletStore, TokenPriceFact, User,
};
146 changes: 146 additions & 0 deletions crates/policy-server/db/src/stores/audit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//! Policy Hub API keys and client-reported audit events (`POST /v1/audit`).
//!
//! Keys are stored hashed (SHA-256 hex, computed by the server crate); this
//! module never sees plaintext. Audit rows are an append-only event log keyed
//! by `(api_key_id, event_id)` — a replay of the same event id from the same
//! key is not an error, it is simply not recorded twice.

use sqlx_core::query::query;
use sqlx_core::row::Row;
use sqlx_postgres::PgPool;
use uuid::Uuid;

use crate::error::{DbError, DbResult};

/// One issued API key (never the plaintext).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ApiKeyRow {
/// Stable key id (what `issue_api_key --revoke` takes).
pub id: Uuid,
/// Operator-chosen label, e.g. `browser-extension`.
pub label: String,
/// Unix seconds.
pub created_at: i64,
/// Unix seconds once revoked; `None` while active.
pub revoked_at: Option<i64>,
}

/// Insert a new key record. `key_hash` is the SHA-256 hex of the plaintext.
///
/// # Errors
///
/// Returns [`DbError`] if the label is blank or the insert fails (including a
/// duplicate hash).
pub async fn create_api_key(
pool: &PgPool,
key_hash: &str,
label: &str,
now: i64,
) -> DbResult<Uuid> {
let label = label.trim();
if label.is_empty() {
return Err(DbError::Invariant("api key label is required".to_owned()));
}
let id = Uuid::new_v4();
query(
"INSERT INTO api_keys (id, key_hash, label, created_at)
VALUES ($1, $2, $3, $4)",
)
.bind(id)
.bind(key_hash)
.bind(label)
.bind(now)
.execute(pool)
.await
.map_err(|e| DbError::Invariant(e.to_string()))?;
Ok(id)
}

/// Look up a non-revoked key by hash. `None` covers unknown and revoked alike
/// so the caller cannot distinguish them (and neither can an attacker).
///
/// # Errors
///
/// Returns [`DbError`] if the query fails.
pub async fn find_active_api_key(pool: &PgPool, key_hash: &str) -> DbResult<Option<ApiKeyRow>> {
let row = query(
"SELECT id, label, created_at, revoked_at
FROM api_keys
WHERE key_hash = $1 AND revoked_at IS NULL",
)
.bind(key_hash)
.fetch_optional(pool)
.await
.map_err(|e| DbError::Invariant(e.to_string()))?;
Ok(row.map(|r| ApiKeyRow {
id: r.get("id"),
label: r.get("label"),
created_at: r.get("created_at"),
revoked_at: r.get("revoked_at"),
}))
}

/// Mark a key revoked. Returns `false` when it was unknown or already revoked.
///
/// # Errors
///
/// Returns [`DbError`] if the update fails.
pub async fn revoke_api_key(pool: &PgPool, id: Uuid, now: i64) -> DbResult<bool> {
let res = query("UPDATE api_keys SET revoked_at = $2 WHERE id = $1 AND revoked_at IS NULL")
.bind(id)
.bind(now)
.execute(pool)
.await
.map_err(|e| DbError::Invariant(e.to_string()))?;
Ok(res.rows_affected() > 0)
}

/// One audit event as reported by a host. Field semantics: registry-api/openapi.yaml `AuditEvent`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NewAuditEvent<'a> {
/// Key the host authenticated with; half of the dedup key.
pub api_key_id: Uuid,
/// Client-generated id; other half of the dedup key.
pub event_id: &'a str,
/// `0x` + 64 lowercase hex — a hash of the request, never its content.
pub request_digest: &'a str,
/// `allow` | `warn` | `deny` (validated by the handler; CHECK-enforced).
pub verdict: &'a str,
/// Bundle `sequence` the verdict was computed against.
pub policy_version: &'a str,
/// dambi-core / engine version string.
pub engine_version: &'a str,
/// Client clock, unix seconds, optional.
pub submitted_at: Option<i64>,
/// Server clock, unix seconds.
pub received_at: i64,
}

/// Append one event. Returns `true` if it was newly recorded, `false` if the
/// same `(api_key_id, event_id)` already existed (idempotent replay).
///
/// # Errors
///
/// Returns [`DbError`] if the insert fails (e.g. the key row is gone or the
/// verdict violates the table CHECK).
pub async fn insert_audit_event(pool: &PgPool, ev: &NewAuditEvent<'_>) -> DbResult<bool> {
let res = query(
"INSERT INTO audit_events
(api_key_id, event_id, request_digest, verdict, policy_version, engine_version,
submitted_at, received_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (api_key_id, event_id) DO NOTHING",
)
.bind(ev.api_key_id)
.bind(ev.event_id)
.bind(ev.request_digest)
.bind(ev.verdict)
.bind(ev.policy_version)
.bind(ev.engine_version)
.bind(ev.submitted_at)
.bind(ev.received_at)
.execute(pool)
.await
.map_err(|e| DbError::Invariant(e.to_string()))?;
Ok(res.rows_affected() > 0)
}
1 change: 1 addition & 0 deletions crates/policy-server/db/src/stores/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! PostgreSQL-backed stores used by the policy server.

pub mod audit;
pub mod market;
pub mod postgres;

Expand Down
56 changes: 55 additions & 1 deletion crates/policy-server/server/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ servers:
description: Local dev

tags:
- name: audit
description: Host-reported verdicts (API key, not user JWT)
- name: auth
description: Google OAuth + JWT lifecycle
- name: wallets
Expand Down Expand Up @@ -800,17 +802,58 @@ paths:
get:
tags: [meta]
summary: Server-Sent Events feed (wallet sync and tx lifecycle)
description: Requires `Authorization: Bearer <jwt>`. Native EventSource cannot set headers; clients must use a header-capable SSE/fetch stream.
description: "Requires `Authorization: Bearer <jwt>`. Native EventSource cannot set headers; clients must use a header-capable SSE/fetch stream."
responses:
"200":
description: SSE stream

/v1/audit:
post:
tags: [audit]
summary: Record a client-computed judgment (deduplicated per key + event id)
description: |
The integrating host reports the verdict its local dambi-core computed.
The server never re-runs Cedar. Authenticates with `X-Api-Key`
(issued by the `issue_api_key` binary; one key = one host app, no
tenant grouping in v0.1 — ADR 0001). Unknown fields are rejected:
no wallet address, calldata or signature can reach this endpoint.
Replaying the same `event_id` under the same key returns 202 with
`recorded: false`.
security:
- apiKeyAuth: []
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AuditEvent" }
responses:
"202":
description: Accepted (recorded, or already recorded)
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean }
event_id: { type: string }
recorded: { type: boolean, description: false when this (key, event_id) was already stored }
"400":
description: Malformed event (bad digest/verdict/id, unknown field, invalid JSON)
"401":
description: Missing, malformed, unknown or revoked API key
"503":
description: Key or audit store unavailable

components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
apiKeyAuth:
type: apiKey
in: header
name: X-Api-Key

parameters:
AddressParam:
Expand All @@ -821,6 +864,17 @@ components:
example: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"

schemas:
AuditEvent:
type: object
additionalProperties: false
required: [event_id, request_digest, verdict, policy_version, engine_version]
properties:
event_id: { type: string, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$", description: Client-generated dedup key. }
request_digest: { type: string, pattern: "^0x[0-9a-f]{64}$", description: Hash of the evaluated request — never the raw calldata or signature payload. }
verdict: { type: string, enum: [allow, warn, deny] }
policy_version: { type: string, maxLength: 64, description: Bundle `sequence` the verdict was computed against. }
engine_version: { type: string, maxLength: 64 }
submitted_at: { type: integer, minimum: 0, description: Unix seconds, client clock. }
CreateMarketReportRequest:
type: object
required: [reason]
Expand Down
Loading
Loading