ada-agent 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/docs/enterprise-stage2-oidc.md +135 -0
- package/docs/enterprise.md +134 -0
- package/package.json +1 -1
- package/src/client/cli.ts +98 -9
- package/src/client/settings.ts +21 -3
- package/src/selfcheck.ts +153 -0
- package/src/server/enterprise.ts +408 -0
- package/src/server/index.ts +277 -25
- package/src/server/oauth.ts +17 -12
- package/src/server/oidc.ts +300 -0
- package/src/server/providers/anthropic.ts +10 -1
- package/src/server/providers/openai-compat.ts +23 -4
package/README.md
CHANGED
|
@@ -245,6 +245,19 @@ See **[docs/architecture.md](docs/architecture.md)** for the design (adapters, r
|
|
|
245
245
|
flow, file layout), **[docs/orchestration.md](docs/orchestration.md)** for the agent strategies, and
|
|
246
246
|
**[docs/integrations.md](docs/integrations.md)** for the HTTP API / SDK / ACP.
|
|
247
247
|
|
|
248
|
+
## Enterprise
|
|
249
|
+
|
|
250
|
+
`ada-server` doubles as an org **control plane**: per-user seat keys, an org policy (server-enforced
|
|
251
|
+
model allowlist + tool rules pushed to every client), per-user usage metering, and an audit log —
|
|
252
|
+
activated only when you create seats, file-backed, self-hosted in your own network. See
|
|
253
|
+
**[docs/enterprise.md](docs/enterprise.md)** for the 2-minute bootstrap.
|
|
254
|
+
|
|
255
|
+
**SSO (OIDC)** — federate login to your IdP (Okta, Entra single-tenant, Auth0, Keycloak, Google
|
|
256
|
+
Workspace): `ADA_OIDC_ISSUER=… ada-server`, then developers run `ada login oidc`. The backend
|
|
257
|
+
JIT-provisions a seat for the verified identity and offboarding is immediate. Stdlib-only RS256/JWKS
|
|
258
|
+
verification, no new dependency; fail-closed by construction. See
|
|
259
|
+
**[docs/enterprise-stage2-oidc.md](docs/enterprise-stage2-oidc.md)**.
|
|
260
|
+
|
|
248
261
|
## Benchmarks
|
|
249
262
|
|
|
250
263
|
ada can run **SWE-bench Verified** — it generates patches for real GitHub issues (one isolated repo
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Enterprise Stage 2 — OIDC SSO + JIT seat provisioning
|
|
2
|
+
|
|
3
|
+
Status: **shipped in 0.8.0**. This is the design record; the operator runbook is in
|
|
4
|
+
[enterprise.md](enterprise.md#sso-oidc--federate-login-to-your-idp).
|
|
5
|
+
|
|
6
|
+
## Why this, and why not SAML
|
|
7
|
+
|
|
8
|
+
After Stage 1 (seats, model allowlist, provider pinning, metering, audit), the #1 enterprise
|
|
9
|
+
security-review gate for a tool that **holds provider API keys** and **reads source code** is
|
|
10
|
+
centralized identity with provable, timely deprovisioning — *"when Alice is offboarded, is her access
|
|
11
|
+
gone in minutes?"* Static `ada_sk_` seat keys can't answer that.
|
|
12
|
+
|
|
13
|
+
OIDC beat the alternatives on unblock-value ÷ cost, grounded in the real code:
|
|
14
|
+
|
|
15
|
+
- **vs SAML** — a terminal has no device grant, so SAML needs a brand-new loopback auth-code+PKCE
|
|
16
|
+
flow *and* an XML-DSig / exclusive-C14N assertion verifier that Node's stdlib can't provide (forcing
|
|
17
|
+
a dependency or a hand-rolled, XSW-prone verifier). OIDC reuses the existing RFC 8628 device flow
|
|
18
|
+
(`oauth.ts`) and needs only stdlib RS256/JWKS verification. Most SAML-mandating IdPs speak OIDC too.
|
|
19
|
+
SAML is deferred to a fast-follow for a contractually SAML-only account.
|
|
20
|
+
- **vs a seatless verify-only login** — real buyers gate on *demonstrable* deprovisioning (SOC 2
|
|
21
|
+
CC6.3 / ISO 27001 A.9.2.6). Verify-only bounds revocation by IdP token lifetime with no kill switch.
|
|
22
|
+
We take exactly one rung more: a JIT seat + an immediate `disable-by-externalId` admin endpoint.
|
|
23
|
+
|
|
24
|
+
Ranking: **oidc-sso (build now) > SCIM (Stage 3) > audit→SIEM export (Stage 4) > SAML (Stage 5a) >
|
|
25
|
+
durable-store/HA (Stage 5c)**.
|
|
26
|
+
|
|
27
|
+
## Goals / non-goals
|
|
28
|
+
|
|
29
|
+
**Goals:** federate login to the org's single-tenant OIDC IdP via the device flow; JIT-provision a
|
|
30
|
+
seat keyed to a stable, non-secret, issuer-scoped `externalId` (`iss#sub`); fail closed everywhere;
|
|
31
|
+
RS256/JWKS verification with **zero new dependencies**; an immediate admin deprovision path.
|
|
32
|
+
|
|
33
|
+
**Non-goals (sequenced later, and honest about it):** SCIM auto-provisioning (Stage 3); SIEM/audit
|
|
34
|
+
**export** (Stage 4 — Stage 2 only *appends* login events, it makes no SIEM-readiness claim); SAML
|
|
35
|
+
(5a); secrets-at-rest encryption of `credentials.json`/`users.json` (5b); Postgres/HA (5c);
|
|
36
|
+
multi-tenant issuers (rejected at load).
|
|
37
|
+
|
|
38
|
+
## Architecture — model B (seat key as the durable bearer)
|
|
39
|
+
|
|
40
|
+
The ID token is a **one-time provisioning artifact**, not a per-request credential. This is what makes
|
|
41
|
+
headless work and keeps the long-lived replayable secret a *server-minted, disableable* seat key
|
|
42
|
+
rather than a stealable `id_token`.
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
1. Client startup: GET /v1/whoami → 401 (backend locked because ADA_OIDC_ISSUER is set).
|
|
46
|
+
2. Client GET /v1/auth/methods (unauth) → { methods:["oidc"], oidc:{ issuer, clientId,
|
|
47
|
+
deviceAuthEndpoint, tokenEndpoint, scope, exchangePath } }. ← client needs NO OIDC env of its own
|
|
48
|
+
3. `ada login oidc` runs the device flow against the IdP (browser; MFA/conditional-access enforced
|
|
49
|
+
BY the IdP). Gets { id_token, access_token, ... }.
|
|
50
|
+
4. Client POST {exchangePath} Authorization: Bearer <id_token>
|
|
51
|
+
backend: verifyOidcToken → isProvisionAllowed (group/domain) → upsertSeatForSSO(iss#sub)
|
|
52
|
+
→ audit sso_login → 200 { seat_key: "ada_sk_…", user, role }
|
|
53
|
+
client: store the SEAT KEY under the "oidc" credential (not the id_token).
|
|
54
|
+
5. Every later request (chat/embeddings/models/whoami, and ALL of -p / serve / acp) sends the
|
|
55
|
+
ada_sk_ seat key through the hardened identifySeat hot path. No per-request JWKS/RSA, no network.
|
|
56
|
+
6. Offboarding: admin POST /v1/users/disable-by-external { externalId } → next request 401; re-login
|
|
57
|
+
refused (disabled seat is never resurrected).
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Files, endpoints, env
|
|
61
|
+
|
|
62
|
+
**New** `src/server/oidc.ts` (stdlib-only): `oidcEnabled()`, `oidcConfig()` (validates + fails closed),
|
|
63
|
+
`assertOidcConfig()` (startup abort), `discover()` (cached openid-configuration), `verifyOidcToken()`
|
|
64
|
+
(RS256/JWKS with a `getKey`/`now` seam for tests), `isProvisionAllowed()`, `mapIdentityToSeatFields()`.
|
|
65
|
+
|
|
66
|
+
**Changed** `src/server/enterprise.ts`: `Seat` gains `externalId?`/`iss?`; `seatByExternalId()`,
|
|
67
|
+
`upsertSeatForSSO()`, `disableSeatByExternalId()`. `index.ts`: `locked() += oidcEnabled()`; `identify()`
|
|
68
|
+
skips GitHub/Google when OIDC is on; pre-auth `GET /v1/auth/methods` + `POST /v1/auth/oidc/exchange`;
|
|
69
|
+
admin `POST /v1/users/disable-by-external`; startup `assertOidcConfig()`. `oauth.ts`: `deviceGrant()`
|
|
70
|
+
(returns the raw token response incl. `id_token`); `deviceLogin()` builds on it. `cli.ts`:
|
|
71
|
+
`identityToken()` returns the OIDC seat key; `oidcLogin()` + `ensureAuth()` self-configure from
|
|
72
|
+
`/v1/auth/methods`.
|
|
73
|
+
|
|
74
|
+
| Endpoint | Auth | Purpose |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `GET /v1/auth/methods` | none | advertise enabled login methods + public OIDC endpoints |
|
|
77
|
+
| `POST /v1/auth/oidc/exchange` | Bearer = `id_token` | verify → provision → return a seat key (once). The ONLY JWT entry point. |
|
|
78
|
+
| `POST /v1/users/disable-by-external` | admin | immediate offboarding by `iss#sub` |
|
|
79
|
+
|
|
80
|
+
| Env | Purpose | Gate |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `ADA_OIDC_ISSUER` | master opt-in; `iss` + discovery. **https, single-tenant only.** | unset ⇒ inert; multi-tenant ⇒ refuse start |
|
|
83
|
+
| `ADA_OIDC_CLIENT_ID` | device-flow app id; expected `aud`/`azp` | required with issuer |
|
|
84
|
+
| `ADA_OIDC_ALLOWED_GROUPS` | groups permitted to provision | **unset + no domains ⇒ refuse start** |
|
|
85
|
+
| `ADA_OIDC_ALLOWED_DOMAINS` | alt allow-surface (email domains) | — |
|
|
86
|
+
| `ADA_OIDC_ADMIN_GROUP` | group ⇒ `role:"admin"` | unset ⇒ all `dev` |
|
|
87
|
+
| `ADA_OIDC_GROUP_CLAIM` / `ADA_OIDC_NAME_CLAIM` | claim names | `groups` / email→preferred_username→sub |
|
|
88
|
+
| `ADA_OIDC_JWKS_URI` / `ADA_OIDC_AUDIENCE` / `ADA_OIDC_CLOCK_SKEW_MS` / `ADA_OIDC_SCOPE` | overrides | discovered / =client id / 120000 / `openid profile email` |
|
|
89
|
+
|
|
90
|
+
## Security model — the fail-closed points (each resolves a red-team finding)
|
|
91
|
+
|
|
92
|
+
- **Backend locks on OIDC.** `locked()` includes `oidcEnabled()`, so a fresh SSO deployment with zero
|
|
93
|
+
seats never falls to the dev-open `{user:"dev"}` branch. *(Fails closed at `locked()`.)*
|
|
94
|
+
- **No open provisioning.** `isProvisionAllowed()` requires a **positive** group/domain match, and the
|
|
95
|
+
server **refuses to start** without an allow-surface — a public issuer can't JIT a seat for every
|
|
96
|
+
account it will sign a token for. Domain matches require a **verified** email (`email_verified:true`
|
|
97
|
+
in the token) so a self-service IdP can't provision an unverified `attacker@corp.com`; IdPs that omit
|
|
98
|
+
the claim must use a group allowlist. *(Fails closed at startup + per exchange.)*
|
|
99
|
+
- **Legacy shared keys refused under SSO.** `ADA_CLIENT_KEYS` is ignored whenever OIDC is on (single
|
|
100
|
+
identity authority), so a still-configured shared key can't bypass verification before the first
|
|
101
|
+
seat is minted.
|
|
102
|
+
- **Issuer-scoped identity.** `externalId = iss#sub`; multi-tenant issuers rejected at load — a reused
|
|
103
|
+
`sub` across tenants can't collapse two people onto one seat. *(Fails closed at config load.)*
|
|
104
|
+
- **Immediate, demonstrable deprovisioning.** Model B + `disableSeatByExternalId` +
|
|
105
|
+
`/v1/users/disable-by-external`: a disabled seat 401s on the next request and re-login is refused,
|
|
106
|
+
independent of token lifetime. *(Fails closed in `identifySeat`/`upsertSeatForSSO`.)*
|
|
107
|
+
- **One JWT entry point.** The `id_token` is accepted only at `/v1/auth/oidc/exchange`; it never
|
|
108
|
+
reaches the per-request identity path, so it can't be forwarded to GitHub/Google verification.
|
|
109
|
+
- **Verifier hardening.** `alg` allowlisted to RS256 (rejects `none`/`HS*` key-confusion); `aud` must
|
|
110
|
+
include the client id; `azp` (when present) must equal the client id, and a multi-audience token
|
|
111
|
+
without `azp` is rejected; `exp`/`nbf`/`iat` checked with bounded skew. JWKS fetches are rate-capped
|
|
112
|
+
(≤1 refetch/60s) so an attacker-chosen `kid` can't amplify fetches; `jwks_uri` must be https and is
|
|
113
|
+
blocked from loopback/private hosts (a lightweight SSRF guard — origin is not pinned to the issuer
|
|
114
|
+
because Google Workspace serves JWKS from a different origin).
|
|
115
|
+
- **One identity authority.** GitHub/Google login is disabled while OIDC is on, so a person disabled
|
|
116
|
+
in the IdP can't re-enter via a still-allowed GitHub account. Admin role is downgraded to dev on a
|
|
117
|
+
login that no longer carries the admin group (privilege revocation); escalation stays an explicit
|
|
118
|
+
admin action.
|
|
119
|
+
|
|
120
|
+
## Verification
|
|
121
|
+
|
|
122
|
+
`npm run typecheck` + `npm run selfcheck` (hermetic: JIT mint/reuse/no-escalation, immediate
|
|
123
|
+
deprovision-by-externalId, prototype-safe scan, and a full RS256 verify test with a local keypair +
|
|
124
|
+
injected JWKS covering tamper/wrong-aud/`alg=none`/expiry → all `null`). Live-verified: OIDC-locked
|
|
125
|
+
backend 401s a tokenless request, fail-closed startup on missing allow-surface and multi-tenant
|
|
126
|
+
issuer, real-Google discovery + JWKS guard, exchange rejects a bogus token.
|
|
127
|
+
|
|
128
|
+
## Roadmap
|
|
129
|
+
|
|
130
|
+
- **Stage 3 — SCIM 2.0**: admin-gated `/scim/v2/Users` driving `createSeat`/`disableSeatByExternalId`
|
|
131
|
+
on the `iss#sub` index shipped here (no migration). Automates joiner/mover/leaver.
|
|
132
|
+
- **Stage 4 — audit/usage SIEM export**: tee `appendAudit`/`appendUsage` to syslog/webhook/OTLP-JSON;
|
|
133
|
+
audit hash-chain + `GET /v1/audit/verify`. Carries the Stage 2/3 `sso_*`/`scim_*` events.
|
|
134
|
+
- **Stage 5** — SAML (named account only); secrets-at-rest encryption; Postgres/HA at the `dataDir()`
|
|
135
|
+
seam.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Enterprise: seats, policy, metering, audit
|
|
2
|
+
|
|
3
|
+
`ada-server` doubles as an org **control plane**: per-user seat keys, an org policy the backend
|
|
4
|
+
enforces (and clients apply locally), per-user usage metering, and an audit log. It's all
|
|
5
|
+
file-backed under `~/.ada/server/` (override with `ADA_DATA_DIR`) — fine to ~50 seats; a database
|
|
6
|
+
is the upgrade path, not the starting point.
|
|
7
|
+
|
|
8
|
+
**Enterprise mode activates only when a seat exists or `ADA_ADMIN_KEY` is set.** With neither, the
|
|
9
|
+
backend behaves exactly as before (dev-open, or `ADA_CLIENT_KEYS`/login).
|
|
10
|
+
|
|
11
|
+
## Bootstrap (2 minutes)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 1. start the backend with a bootstrap admin key (any long random string)
|
|
15
|
+
export ADA_ADMIN_KEY=$(openssl rand -hex 24)
|
|
16
|
+
export CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=... # your provider keys
|
|
17
|
+
ada-server # banner shows: [ENTERPRISE (0 seats + admin key)]
|
|
18
|
+
|
|
19
|
+
# 2. create a seat per developer — the key is shown ONCE
|
|
20
|
+
curl -s -X POST -H "Authorization: Bearer $ADA_ADMIN_KEY" \
|
|
21
|
+
-d '{"name":"alice"}' http://localhost:8787/v1/users
|
|
22
|
+
# → { "key": "ada_sk_…", "name": "alice", "role": "dev", "note": "shown once — store it now" }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Each developer sets their seat key and points at the backend:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
export ADA_BACKEND_URL=https://ada.yourcompany.com/v1
|
|
29
|
+
export ADA_CLIENT_KEY=ada_sk_…
|
|
30
|
+
ada
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Seats
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
GET /v1/users # list: name, role, keyPrefix, created, disabled (admin)
|
|
37
|
+
POST /v1/users # {"name":"bob","role":"dev"|"admin"} → full key, once (admin)
|
|
38
|
+
DELETE /v1/users/<keyPrefix> # disable a seat (≥12 chars of its key; kept for the audit trail) (admin)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Full keys are never listed after creation — only a 14-char prefix. `ADA_ADMIN_KEY` is the
|
|
42
|
+
break-glass admin; create an admin *seat* for day-to-day and keep the env key in a vault.
|
|
43
|
+
|
|
44
|
+
## SSO (OIDC) — federate login to your IdP
|
|
45
|
+
|
|
46
|
+
Instead of handing out static seat keys, connect your OIDC IdP (Okta, Entra **single-tenant**, Auth0,
|
|
47
|
+
Keycloak, Google Workspace). Developers sign in through the browser via the device flow, and the
|
|
48
|
+
backend **JIT-provisions a seat** for the verified identity. Seats are keyed to a stable, non-secret
|
|
49
|
+
`iss#sub`, so offboarding has a target.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# On the backend (all values are non-secret except any confidential-client secret):
|
|
53
|
+
export ADA_OIDC_ISSUER=https://your-tenant.okta.com # single-tenant issuer URL (https)
|
|
54
|
+
export ADA_OIDC_CLIENT_ID=<device-flow app client id>
|
|
55
|
+
export ADA_OIDC_ALLOWED_GROUPS=ada-users # OR ADA_OIDC_ALLOWED_DOMAINS=yourco.com
|
|
56
|
+
export ADA_OIDC_ADMIN_GROUP=ada-admins # optional: this group → role "admin"
|
|
57
|
+
ada-server # banner: [OIDC SSO (0 seats — awaiting first login)]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Setting `ADA_OIDC_ISSUER` locks the backend immediately** (before any seat exists) — a request
|
|
61
|
+
that doesn't authenticate gets `401`, never dev-open. Two fail-closed guards refuse to start on unsafe
|
|
62
|
+
config: no `ALLOWED_GROUPS`/`ALLOWED_DOMAINS` (would provision every IdP user), or a **multi-tenant**
|
|
63
|
+
issuer (`.../common` or `.../organizations`, where `sub` isn't unique). When OIDC is on, the
|
|
64
|
+
GitHub/Google login path is disabled — the IdP is the single identity authority.
|
|
65
|
+
|
|
66
|
+
The developer needs **no OIDC config** — the client learns it from the backend:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
export ADA_BACKEND_URL=https://ada.yourcompany.com/v1
|
|
70
|
+
ada login oidc # opens the IdP in a browser; on success stores a seat key. Then just `ada`.
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Under the hood the ID token is a **one-time provisioning artifact**: `ada login oidc` exchanges it at
|
|
74
|
+
`POST /v1/auth/oidc/exchange` for a durable `ada_sk_` seat key, and every later request (including
|
|
75
|
+
`-p`, `serve`, `acp`) carries that seat key — so long headless runs never expire mid-job, and
|
|
76
|
+
revocation is a seat-disable, not a token-lifetime wait.
|
|
77
|
+
|
|
78
|
+
**Offboarding (immediate):** disable the seat by its `iss#sub`. The next request 401s and re-login is
|
|
79
|
+
refused (a disabled seat is never resurrected).
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
curl -X POST -H "Authorization: Bearer $ADA_ADMIN_KEY" \
|
|
83
|
+
-d '{"externalId":"https://your-tenant.okta.com#00u1a2b3c4"}' \
|
|
84
|
+
http://localhost:8787/v1/users/disable-by-external
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`GET /v1/auth/methods` (unauthenticated) advertises the enabled methods. ID-token verification is
|
|
88
|
+
stdlib-only (RS256 + JWKS, `alg` allowlisted, `iss`/`aud`/`azp`/`exp`/`nbf` checked) — no new
|
|
89
|
+
dependency. Full env reference and the security model: [enterprise-stage2-oidc.md](enterprise-stage2-oidc.md).
|
|
90
|
+
|
|
91
|
+
Automated joiner/mover/leaver (**SCIM**) and audit **export to a SIEM** are the sequenced next stages;
|
|
92
|
+
today login events (`sso_login`, `sso_login_denied`, `seat_created`, `seat_disabled`) are appended to
|
|
93
|
+
the audit log but not yet streamed out.
|
|
94
|
+
|
|
95
|
+
## Org policy
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
curl -X PUT -H "Authorization: Bearer $ADA_ADMIN_KEY" http://localhost:8787/v1/policy -d '{
|
|
99
|
+
"models": ["@cf/*", "claude-*"],
|
|
100
|
+
"permissions": [
|
|
101
|
+
{ "tool": "web_*", "action": "deny" },
|
|
102
|
+
{ "tool": "bash", "pattern": "*curl*", "action": "ask" }
|
|
103
|
+
]
|
|
104
|
+
}'
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- **`models`** — allowlist (`*` wildcards). Enforced **server-side** (403 + audit entry), so a
|
|
108
|
+
modified client can't route around it. Empty/absent = all models.
|
|
109
|
+
- **`permissions`** — tool rules **pushed to clients** (fetched from `GET /v1/policy` at startup —
|
|
110
|
+
interactive, `-p` headless, `serve`, and `acp` alike). Merged restrictive-wins with local config:
|
|
111
|
+
an org `deny` beats any local `allow`, an org `ask` upgrades a local `allow`, and an org `allow`
|
|
112
|
+
can never *loosen* a local deny or the default gating. **Honest caveat:** tool rules run in the
|
|
113
|
+
*client*, so they govern well-behaved clients — and only **model-allowlist** denials are audited
|
|
114
|
+
server-side; tool-rule outcomes are not visible to `/v1/audit`. The **hard, server-enforced**
|
|
115
|
+
guarantees are: authentication, the model allowlist, provider pinning (when an allowlist is set,
|
|
116
|
+
the client's `provider` hint is ignored so a request can't be re-routed off-policy), and metering.
|
|
117
|
+
|
|
118
|
+
## Usage & audit
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
GET /v1/usage?days=30 # totals + per-user + per-model {requests, promptTokens, completionTokens} (admin)
|
|
122
|
+
GET /v1/audit?limit=200 # seat_created / seat_disabled / policy_updated / policy_denied_model … (admin)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Metering is captured server-side by teeing every chat response (streamed or not) and recording the
|
|
126
|
+
upstream's reported token usage per user — clients can't underreport. Join with
|
|
127
|
+
`ada catalog` prices for cost.
|
|
128
|
+
|
|
129
|
+
## Deployment notes
|
|
130
|
+
|
|
131
|
+
- Run behind TLS (caddy/nginx) — seat keys travel as bearer tokens.
|
|
132
|
+
- `ADA_DATA_DIR` on a persistent volume; back it up (it's 4 small JSON/JSONL files).
|
|
133
|
+
- One deployment = one org. Multi-org/SaaS is deliberately out of scope for v1.
|
|
134
|
+
- Compliance paperwork (SOC 2, DPA) is process, not code — start it when a buyer asks.
|
package/package.json
CHANGED
package/src/client/cli.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
-
import { readFileSync } from "node:fs";
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
9
|
import { stdin, stdout } from "node:process";
|
|
9
10
|
import OpenAI from "openai";
|
|
@@ -11,9 +12,9 @@ import { Agent, type AgentEvent, type ApprovalDecision, type OnApprove } from ".
|
|
|
11
12
|
import { ApprovalRegistry, newId, sseFrame } from "./agent-server.ts";
|
|
12
13
|
import { expandPrompt, loadPrompts } from "./prompts.ts";
|
|
13
14
|
import { Session, list, type SessionMeta } from "./session.ts";
|
|
14
|
-
import { deleteCredential, getCredential, listCredentials } from "../server/credentials.ts";
|
|
15
|
-
import { deviceLogin, oauthConfig } from "../server/oauth.ts";
|
|
16
|
-
import { addTrust, isTrusted, loadSettings, setActiveAgentPermissions, type Settings } from "./settings.ts";
|
|
15
|
+
import { deleteCredential, getCredential, listCredentials, setCredential } from "../server/credentials.ts";
|
|
16
|
+
import { deviceGrant, deviceLogin, oauthConfig } from "../server/oauth.ts";
|
|
17
|
+
import { addTrust, isTrusted, loadSettings, setActiveAgentPermissions, setOrgPermissions, type PermRule, type Settings } from "./settings.ts";
|
|
17
18
|
import { getCommands, loadExtensions } from "./extensions.ts";
|
|
18
19
|
import { registerTool, setAsker } from "./tools.ts";
|
|
19
20
|
import { addRemoteSkill, loadSkills, registerSkillTool } from "./skills.ts";
|
|
@@ -35,8 +36,12 @@ type RL = ReturnType<typeof createInterface>;
|
|
|
35
36
|
|
|
36
37
|
const BACKEND = process.env.ADA_BACKEND_URL ?? "http://localhost:8787/v1";
|
|
37
38
|
|
|
38
|
-
/** A stored
|
|
39
|
+
/** A stored login credential, sent as the bearer so the backend can identify us. An OIDC SSO seat
|
|
40
|
+
* key (ada_sk_…) wins — it's a durable, server-minted, disableable bearer (see oidcLogin). A seat
|
|
41
|
+
* key is only honored by the backend that minted it, so a stray one sent elsewhere just 401s. */
|
|
39
42
|
function identityToken(): string | undefined {
|
|
43
|
+
const oidc = getCredential("oidc");
|
|
44
|
+
if (oidc?.type === "oauth" && oidc.key) return oidc.key;
|
|
40
45
|
for (const p of ["github", "google"]) {
|
|
41
46
|
const c = getCredential(p);
|
|
42
47
|
if (c?.type === "oauth" && c.access) return c.access;
|
|
@@ -352,8 +357,52 @@ function makeClient(): OpenAI {
|
|
|
352
357
|
return new OpenAI({ baseURL: BACKEND, apiKey: clientKey(), maxRetries: 0 });
|
|
353
358
|
}
|
|
354
359
|
|
|
355
|
-
|
|
360
|
+
interface AuthMethods {
|
|
361
|
+
methods: string[];
|
|
362
|
+
oidc?: { issuer: string; clientId: string; deviceAuthEndpoint: string; tokenEndpoint: string; scope: string; exchangePath: string };
|
|
363
|
+
oidcError?: string;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Ask the backend which login methods it offers (so the client needs no OIDC env of its own). */
|
|
367
|
+
async function fetchAuthMethods(): Promise<AuthMethods | null> {
|
|
368
|
+
try {
|
|
369
|
+
const r = await fetch(`${BACKEND}/auth/methods`, { signal: AbortSignal.timeout(4000) });
|
|
370
|
+
if (!r.ok) return null;
|
|
371
|
+
return (await r.json()) as AuthMethods;
|
|
372
|
+
} catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** OIDC SSO login (model B): device-flow against the org IdP using the backend-advertised config,
|
|
378
|
+
* then exchange the id_token for a durable seat key stored under the "oidc" credential. */
|
|
379
|
+
async function oidcLogin(): Promise<boolean> {
|
|
380
|
+
const m = await fetchAuthMethods();
|
|
381
|
+
if (!m?.oidc) {
|
|
382
|
+
console.log(m?.oidcError ? `SSO unavailable: ${m.oidcError}` : "this backend does not offer OIDC SSO.");
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
const { clientId, deviceAuthEndpoint, tokenEndpoint, scope, exchangePath } = m.oidc;
|
|
386
|
+
try {
|
|
387
|
+
const tok = await deviceGrant("SSO", { clientId, deviceUrl: deviceAuthEndpoint, tokenUrl: tokenEndpoint, scope }, (s) => console.log(s));
|
|
388
|
+
const idToken = tok.id_token as string | undefined;
|
|
389
|
+
if (!idToken) throw new Error("IdP returned no id_token — ensure the 'openid' scope is granted");
|
|
390
|
+
const r = await fetch(`${BACKEND}${exchangePath}`, { method: "POST", headers: { authorization: `Bearer ${idToken}` } });
|
|
391
|
+
if (!r.ok) throw new Error(`exchange failed: HTTP ${r.status} ${await r.text().catch(() => "")}`);
|
|
392
|
+
const { seat_key, user, role } = (await r.json()) as { seat_key?: string; user?: string; role?: string };
|
|
393
|
+
if (!seat_key) throw new Error("backend returned no seat_key");
|
|
394
|
+
await setCredential("oidc", { type: "oauth", key: seat_key });
|
|
395
|
+
console.log(`Logged in via SSO as ${user} (${role}).`);
|
|
396
|
+
return true;
|
|
397
|
+
} catch (e) {
|
|
398
|
+
console.error(`SSO login failed: ${e instanceof Error ? e.message : e}`);
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Run the device flow for `provider`; returns true on success (credential stored). */
|
|
356
404
|
async function loginFlow(provider: string): Promise<boolean> {
|
|
405
|
+
if (provider === "oidc" || provider === "sso") return oidcLogin();
|
|
357
406
|
const cfg = oauthConfig(provider);
|
|
358
407
|
if (!cfg) {
|
|
359
408
|
console.log(`No OAuth config for ${provider}. Set ADA_OAUTH_${provider.toUpperCase()}_{CLIENT_ID,DEVICE_URL,TOKEN_URL}.`);
|
|
@@ -368,6 +417,38 @@ async function loginFlow(provider: string): Promise<boolean> {
|
|
|
368
417
|
}
|
|
369
418
|
}
|
|
370
419
|
|
|
420
|
+
/** Fetch org policy from an enterprise backend and apply its tool rules locally (restrictive-wins
|
|
421
|
+
* merge in settings.permissionFor; the backend enforces the model allowlist regardless). Caches the
|
|
422
|
+
* last-good policy under ~/.ada so a transient fetch failure falls back to known rules instead of
|
|
423
|
+
* silently dropping them. No-op against a non-enterprise backend. */
|
|
424
|
+
async function applyOrgPolicy(): Promise<void> {
|
|
425
|
+
const cacheFile = join(homedir(), ".ada", "org-policy.json");
|
|
426
|
+
const enterprise = clientKey().startsWith("ada_sk_"); // a seat key ⇒ this is an enterprise backend
|
|
427
|
+
try {
|
|
428
|
+
const r = await fetch(`${BACKEND}/policy`, { headers: { authorization: `Bearer ${clientKey()}` }, signal: AbortSignal.timeout(3000) });
|
|
429
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
430
|
+
const policy = (await r.json()) as { permissions?: PermRule[] };
|
|
431
|
+
setOrgPermissions(policy.permissions ?? null);
|
|
432
|
+
try {
|
|
433
|
+
mkdirSync(join(homedir(), ".ada"), { recursive: true });
|
|
434
|
+
writeFileSync(cacheFile, JSON.stringify(policy));
|
|
435
|
+
} catch {
|
|
436
|
+
/* cache best-effort */
|
|
437
|
+
}
|
|
438
|
+
if (policy.permissions?.length) console.error(`\x1b[2m↳ org policy applied (${policy.permissions.length} rule${policy.permissions.length === 1 ? "" : "s"})\x1b[0m`);
|
|
439
|
+
} catch (e) {
|
|
440
|
+
if (!enterprise) return; // non-enterprise backend — local rules only, silently
|
|
441
|
+
// Enterprise backend unreachable — fall back to the last policy we saw, and say so loudly.
|
|
442
|
+
try {
|
|
443
|
+
const cached = JSON.parse(readFileSync(cacheFile, "utf8")) as { permissions?: PermRule[] };
|
|
444
|
+
setOrgPermissions(cached.permissions ?? null);
|
|
445
|
+
console.error(`\x1b[33m[warn] could not fetch org policy (${e instanceof Error ? e.message : e}) — using cached rules.\x1b[0m`);
|
|
446
|
+
} catch {
|
|
447
|
+
console.error(`\x1b[33m[warn] could not fetch org policy (${e instanceof Error ? e.message : e}) and no cache — org tool rules NOT applied this session.\x1b[0m`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
371
452
|
/** Startup login check: probe the backend; if it says 401, offer to sign in and rebuild the client. */
|
|
372
453
|
async function ensureAuth(rl: RL, client: OpenAI): Promise<OpenAI> {
|
|
373
454
|
let status: number;
|
|
@@ -378,12 +459,16 @@ async function ensureAuth(rl: RL, client: OpenAI): Promise<OpenAI> {
|
|
|
378
459
|
return client; // backend unreachable — the model fetch will report it
|
|
379
460
|
}
|
|
380
461
|
if (status !== 401) return client; // 200 = already authorized, or backend is open (dev)
|
|
381
|
-
|
|
462
|
+
// Prefer the org IdP if the backend advertises OIDC SSO; else fall back to a locally-configured
|
|
463
|
+
// GitHub/Google OAuth app.
|
|
464
|
+
const methods = await fetchAuthMethods();
|
|
465
|
+
const provider = methods?.methods.includes("oidc") ? "oidc" : ["github", "google"].find((p) => oauthConfig(p));
|
|
382
466
|
if (!provider) {
|
|
383
|
-
console.log("\x1b[33mthis backend requires login, but no
|
|
467
|
+
console.log("\x1b[33mthis backend requires login, but no login method is available (backend offers no SSO and no ADA_OAUTH_* is set).\x1b[0m");
|
|
384
468
|
return client;
|
|
385
469
|
}
|
|
386
|
-
const
|
|
470
|
+
const label = provider === "oidc" ? `your org (${methods?.oidc?.issuer ?? "SSO"})` : provider;
|
|
471
|
+
const ans = (await rl.question(`\x1b[33mnot logged in — sign in with ${label}? [Y/n] \x1b[0m`)).trim().toLowerCase();
|
|
387
472
|
if (ans === "n" || ans === "no") return client;
|
|
388
473
|
return (await loginFlow(provider)) ? makeClient() : client;
|
|
389
474
|
}
|
|
@@ -658,6 +743,7 @@ async function main(): Promise<void> {
|
|
|
658
743
|
registerSkillTool(loadSkills(trusted));
|
|
659
744
|
await loadMcpServers(trusted);
|
|
660
745
|
const client = makeClient();
|
|
746
|
+
await applyOrgPolicy(); // enterprise org rules apply to acp sessions too
|
|
661
747
|
let model = process.env.ADA_MODEL || settings.model || "";
|
|
662
748
|
if (!model) {
|
|
663
749
|
try {
|
|
@@ -741,6 +827,7 @@ async function main(): Promise<void> {
|
|
|
741
827
|
registerSkillTool(loadSkills(trusted));
|
|
742
828
|
await loadMcpServers(trusted);
|
|
743
829
|
const client = makeClient();
|
|
830
|
+
await applyOrgPolicy(); // enterprise org rules apply to serve sessions too
|
|
744
831
|
let model = (process.argv[3] && !process.argv[3].startsWith("--") ? process.argv[3] : "") || process.env.ADA_MODEL || settings.model || "";
|
|
745
832
|
if (!model) {
|
|
746
833
|
try {
|
|
@@ -1082,6 +1169,7 @@ async function main(): Promise<void> {
|
|
|
1082
1169
|
if (flags.print !== undefined) {
|
|
1083
1170
|
const trusted = isTrusted(process.cwd());
|
|
1084
1171
|
const settings = loadSettings(trusted);
|
|
1172
|
+
await applyOrgPolicy(); // org tool rules bind headless runs too (CI is the classic bypass path)
|
|
1085
1173
|
let pm = flags.model ?? process.env.ADA_MODEL ?? settings.model ?? scoped[0] ?? "";
|
|
1086
1174
|
if (!pm) {
|
|
1087
1175
|
try {
|
|
@@ -1125,6 +1213,7 @@ async function main(): Promise<void> {
|
|
|
1125
1213
|
const mcp = await loadMcpServers(includeProject);
|
|
1126
1214
|
|
|
1127
1215
|
client = await ensureAuth(rl, client); // always check login at startup; prompt if the backend says 401
|
|
1216
|
+
await applyOrgPolicy(); // enterprise backends push org tool rules; no-op otherwise
|
|
1128
1217
|
|
|
1129
1218
|
let session: Session;
|
|
1130
1219
|
let history: Msg[] = [];
|
package/src/client/settings.ts
CHANGED
|
@@ -66,9 +66,15 @@ export function setActiveAgentPermissions(rules: PermRule[] | null): void {
|
|
|
66
66
|
activeAgentPerms = rules;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
// Org policy pushed by an enterprise backend (fetched from /v1/policy at startup). Merged
|
|
70
|
+
// restrictive-wins: an org "deny" beats any local "allow"; an org "ask" upgrades a local "allow".
|
|
71
|
+
// A local "deny" always stands — the org can tighten a user's setup, never loosen it.
|
|
72
|
+
let orgPerms: PermRule[] | null = null;
|
|
73
|
+
export function setOrgPermissions(rules: PermRule[] | null): void {
|
|
74
|
+
orgPerms = rules?.length ? rules : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function evalRules(rules: PermRule[], toolName: string, summary: string): PermAction | null {
|
|
72
78
|
let result: PermAction | null = null;
|
|
73
79
|
for (const r of rules) {
|
|
74
80
|
const toolOk = !r.tool || r.tool === toolName || globMatch(r.tool, toolName);
|
|
@@ -78,6 +84,18 @@ export function permissionFor(toolName: string, summary: string): PermAction | n
|
|
|
78
84
|
return result;
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
const STRICTNESS: Record<PermAction, number> = { allow: 0, ask: 1, deny: 2 };
|
|
88
|
+
|
|
89
|
+
/** Evaluate the configured permission rules for a tool call. null = no matching rule (use defaults). */
|
|
90
|
+
export function permissionFor(toolName: string, summary: string): PermAction | null {
|
|
91
|
+
const local = evalRules(activeAgentPerms ?? loadSettings(isTrusted(process.cwd())).permissions ?? [], toolName, summary);
|
|
92
|
+
if (!orgPerms) return local;
|
|
93
|
+
const org = evalRules(orgPerms, toolName, summary);
|
|
94
|
+
if (org === null) return local;
|
|
95
|
+
if (local === null) return org === "allow" ? null : org; // org can't LOOSEN the default gating, only tighten
|
|
96
|
+
return STRICTNESS[org] > STRICTNESS[local] ? org : local;
|
|
97
|
+
}
|
|
98
|
+
|
|
81
99
|
export function addTrust(dir: string): void {
|
|
82
100
|
const g = readJson(GLOBAL);
|
|
83
101
|
const dirs = new Set(g.trustedDirs ?? []);
|