@checkstack/auth-common 0.7.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/CHANGELOG.md +70 -0
- package/package.json +4 -4
- package/src/bind-authority.test.ts +53 -0
- package/src/bind-authority.ts +30 -0
- package/src/index.ts +1 -0
- package/src/routes.ts +8 -0
- package/src/rpc-contract.ts +74 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,75 @@
|
|
|
1
1
|
# @checkstack/auth-common
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9dcc848: Add the AI platform: a transport-agnostic tool spine, an OAuth Authorization Server + read-only MCP server, a propose/apply flow with audit log, a streaming in-app chat agent, per-conversation permission modes, per-integration spend caps, and user-scoped tool authorization.
|
|
8
|
+
|
|
9
|
+
Two new packages, `@checkstack/ai-common` (the `AiTool` contract, `read`/`mutate`/`destructive` effect classification, the `ai.*` access rules, the OpenAI-compatible connection shape, and the wire contracts) and `@checkstack/ai-backend` (the tool registry, extension points, principal-to-tool resolver, shared zod-to-JSON-Schema serializer, and all transports). The OpenAI-compatible integration provider registers through the existing integration provider extension point, so its API key is stored in the Secrets Vault and configured in the generic Connections UI.
|
|
10
|
+
|
|
11
|
+
What ships:
|
|
12
|
+
|
|
13
|
+
- Tool spine and extension points: `aiToolExtensionPoint.registerTool` (hand-authored composite tools) and `aiToolProjectionExtensionPoint.expose` (opt-in projections of existing oRPC procedures). Authorization mirrors `autoAuthMiddleware` exactly - a tool is surfaced only when every `requiredAccessRules` entry is satisfied, so a scope-narrowed principal can only ever see fewer tools.
|
|
14
|
+
- OAuth + MCP: Checkstack can act as its own OAuth 2.1 Authorization Server (authorization code + PKCE, consent screen, Dynamic Client Registration) and expose a read-only MCP server over Streamable HTTP at `/api/ai/mcp`. Off by default, enabled by the admin `ai.mcp-oauth` setting. A Bearer OAuth-token branch is added to the auth strategy; token scopes are intersected live with the bound user's access rules on every call. A shared-Postgres rate limiter throttles the DCR endpoint per client IP. `getMcpOAuthSettings` / `setMcpOAuthSettings` contracts added to `@checkstack/auth-common`. A minimal OAuth consent page (`/auth/oauth-consent`) renders the requesting client and scopes.
|
|
15
|
+
- Propose/apply + audit: a transport-agnostic two-step service - `propose` re-checks authz, runs the tool's `dryRun` without mutating, and returns a single-use proposal token (the `proposed` audit row IS the token store, 10-minute TTL, atomic single-use); `apply` re-parses the server-stored payload, re-checks authz, and atomically commits. The `ai_tool_calls` audit table records every call across both transports with a SHA-256 args hash (never raw arguments) and stamps who proposed and who applied. An `ai.toolCalled` event carries metadata only.
|
|
16
|
+
- In-app chat: a server-side, provider-agnostic Vercel AI SDK agent loop (OpenAI, Azure, OpenRouter, Ollama, vLLM, LM Studio, ...). The model provider is built on the backend from the integration credentials, so the API key never leaves the backend. The loop offers only resolver-allowed tools, auto-runs read tools (re-entering the live router as the logged-in user) and routes mutating / destructive tools through propose/apply. Durable conversation persistence (`ai_conversations`, `ai_messages`, owner-scoped RPCs) plus a streaming chat UI with a confirm-card component and per-integration model picker.
|
|
17
|
+
- Per-conversation permission mode (Claude-Code-style approve/auto), a durable `permission_mode` column on `ai_conversations` (default `approve`). `read` always auto-runs in both modes; `mutate` inherits the mode (auto-applies server-side in `auto`, confirm-carded in `approve`); `destructive` ALWAYS requires the human `applyTool` in both modes. Security invariant (structural + tested): the mode is consulted only on the `mutate` branch, so no `(effect, mode)` pair routes a destructive tool to auto-apply.
|
|
18
|
+
- Per-integration LLM spend cap (optional `spendCap` = `tokenBudget` + `windowMinutes`, default OFF). Spend is tracked in a shared-Postgres `ai_spend` ledger; enforcement is a rolling-window SUM run before each turn (HTTP 429 over budget). Per-principal tool rate-limit budgets are a rolling COUNT over `ai_tool_calls`, enforced on both transports. An absent / empty / incomplete `spendCap` is treated as "no cap" rather than rejected.
|
|
19
|
+
- Full tool-call replay: `ai_messages.model_messages` (jsonb) persists the canonical AI-SDK `ResponseMessage[]` per turn and replays them verbatim on the next turn; legacy rows fall back to text-only replay.
|
|
20
|
+
- Enforced no-secret-leak scrubbing: `appendMessage` runs `scrubContent` on every write, redacting credential-shaped keys and high-confidence credential values; a canary regression test asserts injected secrets are stripped. A hardening test suite asserts no secret appears in any AI-surface DTO and that handler-side authz holds when the model misbehaves.
|
|
21
|
+
- Provider correctness: the chat provider uses `@ai-sdk/openai-compatible`'s `chatModel` (plain `/chat/completions`), so OpenAI-compatible gateways (OpenRouter, DeepSeek, Ollama, vLLM) no longer reject turns with `invalid_prompt`; `@ai-sdk/openai` is removed.
|
|
22
|
+
|
|
23
|
+
BREAKING CHANGES:
|
|
24
|
+
|
|
25
|
+
- The `AiTool` contract (`@checkstack/ai-common`) gained a `TRpc` type parameter, and both `dryRun` and `execute` now receive a USER-SCOPED `rpcClient` arg bound to the originating user. Every plugin procedure a tool calls re-enters the live router AS THAT USER, so handler-side authorization (access rules AND per-resource/team scope) is enforced exactly as a direct UI/RPC call - closing a prior privilege-escalation where tools captured a trusted service client at construction. A hand-authored tool MUST resolve its plugin client from this per-call arg and MUST NOT capture a trusted service client at factory scope. Tool factories that previously took `{ rpcClient }` should drop that parameter.
|
|
26
|
+
- `AiToolProjectionExtensionPoint.expose` no longer takes a second `pluginMetadata` argument; the owning metadata lives on `input.sourcePluginMetadata`. Callers must drop the second argument.
|
|
27
|
+
|
|
28
|
+
State and scale: conversations, messages, the audit log, proposal tokens, the rate-limit counter, and the spend ledger all live in shared Postgres, so every pod answers identically and the agent loop is resumable on any pod. The only pod-local state is the live MCP connection registry (bookkeeping, never a source of truth). Cross-pod conversation readback, the spend cap, and the tool budget are verified by env-gated two-pod integration tests.
|
|
29
|
+
|
|
30
|
+
This is a beta minor.
|
|
31
|
+
|
|
32
|
+
- 9dcc848: Automations now run as a configured service account, removing implicit god-mode from the dispatch path.
|
|
33
|
+
|
|
34
|
+
BREAKING: every automation must declare a `runAs` application (service account). Previously every automation action ran as the trusted service client, bypassing all access-rule, per-resource, and team-scope checks - so an automation could touch any team's data. Now each automation runs as a bounded `application` principal, and every data-access call an action makes is authorized exactly as that identity. An automation with no `runAs` fails to run with a clear error rather than falling back to the trusted client; legacy automations must be assigned a service account before they run again.
|
|
35
|
+
|
|
36
|
+
What changed:
|
|
37
|
+
|
|
38
|
+
- New top-level field `runAs` on automations (a `run_as_application_id` column + create/update inputs; `AutomationSchema.runAs`). Required on create; GitOps sets it via the `run-as` metadata label.
|
|
39
|
+
- A new `coreServices.rpcClientAs(applicationId)` mints a short-lived, backend-signed app-principal token; the auth service resolves it LIVE to an `application` principal (reusing `enrichApplicationPrincipal`), so it flows through full `autoAuthMiddleware` enforcement. The dispatch engine threads this client into every action's `execute` as the required `context.rpcClient`.
|
|
40
|
+
- Bind authority (anti-escalation): a user may only bind an application whose access rules are a subset of their own (`isApplicationBindable`); `getBindableApplications` lists only bindable apps, and the create/update handlers enforce the check.
|
|
41
|
+
- `notification.sendTransactional` moves from service-only to access-gated (`notification.send`, a new access rule), so an automation's `runAs` can call the built-in `notify_user` / `notification.send` actions; trusted services still bypass via short-circuit.
|
|
42
|
+
- A "Run as (Service Account)" picker in the automation editor, populated from `getBindableApplications` (server-side filtered to bindable apps), seeding from the loaded `runAs` on edit and passing it into create + update. First-class teaching UX: an inline info banner, a blocked Save with an inline hint until one is chosen, and an empty state linking to the Applications admin + docs when none are bindable.
|
|
43
|
+
|
|
44
|
+
State and scale: `runAs` resolution is a pure read over shared tables; the app-principal token is self-contained and verified statelessly, so the per-run client is correct under horizontal scale.
|
|
45
|
+
|
|
46
|
+
This is a beta minor.
|
|
47
|
+
|
|
48
|
+
- 9dcc848: Align workspace dependency versions and migrate React Router to v7.
|
|
49
|
+
|
|
50
|
+
BREAKING CHANGES (React Router v7): All frontend packages now depend on `react-router-dom@^7.16.0`. Previously the workspace declared four divergent ranges (`^6.20.0`, `^6.22.0`, `^7.1.1`, `^7.14.2`), which resolved both `react-router@6` and `react-router@7` into a single bundle. Everything is now unified on v7. The public imports the app uses (`BrowserRouter`, `Routes`, `Route`, `Link`, `NavLink`, `MemoryRouter`, `useNavigate`, `useParams`, `useSearchParams`, `useLocation`) are unchanged between v6 and v7, so no source rewrites were required - but any out-of-tree plugin still on react-router v6 should upgrade to v7 (see the React Router v6 -> v7 upgrade guide) to share the host's single router instance via the import map.
|
|
51
|
+
|
|
52
|
+
Other unified ranges (no API change): `react` -> `^18.3.1`, the `@orpc/*` family (`contract`, `server`, `client`, `tanstack-query`, `openapi`, `zod`) -> `^1.14.4`, and `better-auth` -> `^1.6.13`.
|
|
53
|
+
|
|
54
|
+
Removed the pre-rename `@orpc/react-query` leftover from `@checkstack/frontend-api`; its `createRouterUtils` / `RouterUtils` / `ProcedureUtils` now come from `@orpc/tanstack-query` (the package already in use).
|
|
55
|
+
|
|
56
|
+
Stale in-range runtime deps pulled up to current published versions: `hono` `^4.12.23`, `@tanstack/react-query` (+devtools) `^5.100.14`, `date-fns` `^4.4.0`, `jose` `^6.2.3`, `tar` `^7.5.16`, `semver` `^7.8.1`, `@xyflow/react` `^12.11.0`.
|
|
57
|
+
|
|
58
|
+
### Patch Changes
|
|
59
|
+
|
|
60
|
+
- Updated dependencies [9dcc848]
|
|
61
|
+
- Updated dependencies [9dcc848]
|
|
62
|
+
- Updated dependencies [9dcc848]
|
|
63
|
+
- Updated dependencies [9dcc848]
|
|
64
|
+
- @checkstack/common@0.13.0
|
|
65
|
+
|
|
66
|
+
## 0.7.2
|
|
67
|
+
|
|
68
|
+
### Patch Changes
|
|
69
|
+
|
|
70
|
+
- Updated dependencies [6d52276]
|
|
71
|
+
- @checkstack/common@0.12.0
|
|
72
|
+
|
|
3
73
|
## 0.7.1
|
|
4
74
|
|
|
5
75
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/auth-common",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@checkstack/common": "0.
|
|
14
|
-
"@orpc/contract": "^1.
|
|
13
|
+
"@checkstack/common": "0.12.0",
|
|
14
|
+
"@orpc/contract": "^1.14.4",
|
|
15
15
|
"zod": "^4.0.0"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"@checkstack/tsconfig": "0.0.7",
|
|
19
19
|
"typescript": "^5.7.2",
|
|
20
|
-
"@checkstack/scripts": "0.3.
|
|
20
|
+
"@checkstack/scripts": "0.3.4"
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
23
|
"typecheck": "tsgo -b",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isApplicationBindable } from "./bind-authority";
|
|
3
|
+
|
|
4
|
+
describe("isApplicationBindable", () => {
|
|
5
|
+
test("admin caller (*) may bind any application", () => {
|
|
6
|
+
expect(
|
|
7
|
+
isApplicationBindable({
|
|
8
|
+
appAccessRules: ["incident.incident.manage", "notification.send"],
|
|
9
|
+
callerAccessRules: ["*"],
|
|
10
|
+
}),
|
|
11
|
+
).toBe(true);
|
|
12
|
+
// even an app that itself holds `*`
|
|
13
|
+
expect(
|
|
14
|
+
isApplicationBindable({
|
|
15
|
+
appAccessRules: ["*"],
|
|
16
|
+
callerAccessRules: ["*"],
|
|
17
|
+
}),
|
|
18
|
+
).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("non-admin caller cannot bind an app holding * (escalation)", () => {
|
|
22
|
+
expect(
|
|
23
|
+
isApplicationBindable({
|
|
24
|
+
appAccessRules: ["*"],
|
|
25
|
+
callerAccessRules: ["incident.incident.manage"],
|
|
26
|
+
}),
|
|
27
|
+
).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("bindable when the app's rules are a subset of the caller's", () => {
|
|
31
|
+
expect(
|
|
32
|
+
isApplicationBindable({
|
|
33
|
+
appAccessRules: ["notification.send"],
|
|
34
|
+
callerAccessRules: ["notification.send", "incident.incident.manage"],
|
|
35
|
+
}),
|
|
36
|
+
).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("not bindable when the app holds a rule the caller lacks (escalation)", () => {
|
|
40
|
+
expect(
|
|
41
|
+
isApplicationBindable({
|
|
42
|
+
appAccessRules: ["notification.send", "catalog.system.manage"],
|
|
43
|
+
callerAccessRules: ["notification.send"],
|
|
44
|
+
}),
|
|
45
|
+
).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("an app with no rules is bindable by anyone", () => {
|
|
49
|
+
expect(
|
|
50
|
+
isApplicationBindable({ appAccessRules: [], callerAccessRules: [] }),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bind-authority predicate for automation service accounts.
|
|
3
|
+
*
|
|
4
|
+
* An automation runs as a referenced application (service account). To prevent
|
|
5
|
+
* privilege escalation, a user may only bind an application whose effective
|
|
6
|
+
* access rules are a SUBSET of their own - a user can never grant an automation
|
|
7
|
+
* more authority than they themselves hold.
|
|
8
|
+
*
|
|
9
|
+
* Rules:
|
|
10
|
+
* - A caller holding `*` (admin) may bind any application.
|
|
11
|
+
* - An application holding `*` may only be bound by a `*`-holding caller.
|
|
12
|
+
* - Otherwise every one of the application's access rules must be present in
|
|
13
|
+
* the caller's access rules.
|
|
14
|
+
*
|
|
15
|
+
* This is the single source of truth for the check, used both by the auth
|
|
16
|
+
* backend's `getBindableApplications` (the picker) and by the automation
|
|
17
|
+
* backend's create/update handler (the enforced gate at save time).
|
|
18
|
+
*/
|
|
19
|
+
export function isApplicationBindable({
|
|
20
|
+
appAccessRules,
|
|
21
|
+
callerAccessRules,
|
|
22
|
+
}: {
|
|
23
|
+
appAccessRules: readonly string[];
|
|
24
|
+
callerAccessRules: readonly string[];
|
|
25
|
+
}): boolean {
|
|
26
|
+
if (callerAccessRules.includes("*")) return true;
|
|
27
|
+
if (appAccessRules.includes("*")) return false;
|
|
28
|
+
const caller = new Set(callerAccessRules);
|
|
29
|
+
return appAccessRules.every((rule) => caller.has(rule));
|
|
30
|
+
}
|
package/src/index.ts
CHANGED
package/src/routes.ts
CHANGED
|
@@ -13,4 +13,12 @@ export const authRoutes = createRoutes("auth", {
|
|
|
13
13
|
changePassword: "/change-password",
|
|
14
14
|
profile: "/profile",
|
|
15
15
|
onboarding: "/onboarding",
|
|
16
|
+
/**
|
|
17
|
+
* OAuth 2.1 consent screen for the AI platform's Authorization Server.
|
|
18
|
+
* better-auth's `oidcProvider` redirects the interactive authorization-code
|
|
19
|
+
* flow here (`consentPage: "/auth/oauth-consent"`) with `consent_code`,
|
|
20
|
+
* `client_id`, and `scope` query params; the page POSTs the decision to
|
|
21
|
+
* `/api/auth/oauth2/consent` and follows the returned `redirectURI`.
|
|
22
|
+
*/
|
|
23
|
+
oauthConsent: "/oauth-consent",
|
|
16
24
|
});
|
package/src/rpc-contract.ts
CHANGED
|
@@ -73,6 +73,14 @@ const RegistrationStatusSchema = z.object({
|
|
|
73
73
|
allowRegistration: z.boolean(),
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
/** AI platform OAuth AS + MCP server settings (admin-managed). */
|
|
77
|
+
const McpOAuthSettingsSchema = z.object({
|
|
78
|
+
enabled: z.boolean(),
|
|
79
|
+
allowDynamicClientRegistration: z.boolean(),
|
|
80
|
+
dcrRateLimitMax: z.number().int().positive(),
|
|
81
|
+
dcrRateLimitWindowSeconds: z.number().int().positive(),
|
|
82
|
+
});
|
|
83
|
+
|
|
76
84
|
// Service-to-service schemas
|
|
77
85
|
const FindUserByEmailInputSchema = z.object({
|
|
78
86
|
email: z.string().email(),
|
|
@@ -342,6 +350,26 @@ export const authContract = {
|
|
|
342
350
|
.input(RegistrationStatusSchema)
|
|
343
351
|
.output(z.object({ success: z.boolean() })),
|
|
344
352
|
|
|
353
|
+
// ==========================================================================
|
|
354
|
+
// AI PLATFORM: OAuth AS + MCP server settings (admin-gated)
|
|
355
|
+
// ==========================================================================
|
|
356
|
+
|
|
357
|
+
/** Current OAuth-AS / MCP server settings (enable, DCR toggle, rate-limit). */
|
|
358
|
+
getMcpOAuthSettings: proc({
|
|
359
|
+
operationType: "query",
|
|
360
|
+
userType: "user",
|
|
361
|
+
access: [authAccess.strategies],
|
|
362
|
+
}).output(McpOAuthSettingsSchema),
|
|
363
|
+
|
|
364
|
+
/** Update the OAuth-AS / MCP server settings; reloads better-auth to apply. */
|
|
365
|
+
setMcpOAuthSettings: proc({
|
|
366
|
+
operationType: "mutation",
|
|
367
|
+
userType: "user",
|
|
368
|
+
access: [authAccess.strategies],
|
|
369
|
+
})
|
|
370
|
+
.input(McpOAuthSettingsSchema)
|
|
371
|
+
.output(z.object({ success: z.boolean() })),
|
|
372
|
+
|
|
345
373
|
// ==========================================================================
|
|
346
374
|
// INTERNAL SERVICE ENDPOINTS (userType: "service")
|
|
347
375
|
// ==========================================================================
|
|
@@ -486,6 +514,52 @@ export const authContract = {
|
|
|
486
514
|
.input(z.string())
|
|
487
515
|
.output(z.object({ secret: z.string() })),
|
|
488
516
|
|
|
517
|
+
/**
|
|
518
|
+
* S2S: resolve an application's CURRENT roles, access rules, and team
|
|
519
|
+
* memberships. Used by the core auth service to build an `application`
|
|
520
|
+
* principal from an app-principal token (automation `runAs` service
|
|
521
|
+
* accounts) - resolved live on every call, never frozen into the token.
|
|
522
|
+
* Returns `null` when the application no longer exists.
|
|
523
|
+
*/
|
|
524
|
+
enrichApplicationPrincipal: proc({
|
|
525
|
+
operationType: "query",
|
|
526
|
+
userType: "service",
|
|
527
|
+
access: [],
|
|
528
|
+
})
|
|
529
|
+
.input(z.object({ applicationId: z.string() }))
|
|
530
|
+
.output(
|
|
531
|
+
z
|
|
532
|
+
.object({
|
|
533
|
+
id: z.string(),
|
|
534
|
+
name: z.string(),
|
|
535
|
+
roles: z.array(z.string()),
|
|
536
|
+
accessRules: z.array(z.string()),
|
|
537
|
+
teamIds: z.array(z.string()),
|
|
538
|
+
})
|
|
539
|
+
.nullable(),
|
|
540
|
+
),
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* List the applications the calling user may BIND as an automation's
|
|
544
|
+
* service account. An application is bindable only when its resolved
|
|
545
|
+
* access rules are a subset of the caller's (a user can never grant an
|
|
546
|
+
* automation more authority than they hold); `*`-holders may bind any
|
|
547
|
+
* application. Drives the "Run as" picker in the automation editor.
|
|
548
|
+
*/
|
|
549
|
+
getBindableApplications: proc({
|
|
550
|
+
operationType: "query",
|
|
551
|
+
userType: "user",
|
|
552
|
+
access: [],
|
|
553
|
+
}).output(
|
|
554
|
+
z.array(
|
|
555
|
+
z.object({
|
|
556
|
+
id: z.string(),
|
|
557
|
+
name: z.string(),
|
|
558
|
+
description: z.string().nullable().optional(),
|
|
559
|
+
}),
|
|
560
|
+
),
|
|
561
|
+
),
|
|
562
|
+
|
|
489
563
|
// ==========================================================================
|
|
490
564
|
// TEAM MANAGEMENT (userType: "authenticated" with access)
|
|
491
565
|
// ==========================================================================
|