Skip to content

Commit 392c075

Browse files
author
Eva
committed
feat(plugins): add host-bound Teams resource API
1 parent 0b9a226 commit 392c075

26 files changed

Lines changed: 1657 additions & 34 deletions

docs/plugins/sdk-overview.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,44 @@ the callback button when inbound policy skips the text or processing fails, so
222222
the user can retry after the blocking condition changes. This result field is
223223
Telegram-specific; other channels keep their own interactive result contracts.
224224

225+
### Host-bound Teams resources
226+
227+
`api.teams` is the fail-closed multi-tenant resource seam for plugin gateway
228+
methods. It is available only while the host is executing an isolated-mode
229+
request that already passed a resource authorization decision:
230+
231+
```typescript
232+
const context = api.teams.context.require();
233+
const operation = await api.teams.resources.prepareRegister({
234+
context,
235+
resource: { namespace: "workspaces", type: "tab", id: tabId },
236+
parent: { namespace: "workspaces", type: "workspace", id: workspaceId },
237+
requiredAction: "workspaces.workspace.createTab",
238+
idempotencyKey: requestId,
239+
});
240+
```
241+
242+
The host derives the isolation domain, principal, delegated assignment and
243+
sponsor, request ID, and plugin operation scope. These fields are never
244+
accepted as resource-operation input. Request context objects are
245+
host-authenticated, request-lifetime capabilities; copying or retaining one
246+
outside its current request fails. The required action and parent/resource
247+
must exactly match the decision that admitted the gateway method.
248+
Plugin access policies are copied and frozen by the host, and every resolved
249+
resource must use the loader-supplied plugin ID as its namespace.
250+
251+
Registration and retirement return opaque durable operation handles. Plugins
252+
may persist those handles in an outbox and replay them later with
253+
`api.teams.resources.replayPrepared({ operation })`; the host resolves the
254+
stored domain and binds replay to the loader-supplied plugin ID. Plugins must
255+
not deep-import gateway authorization modules or create parallel tenant,
256+
membership, owner, or grant tables.
257+
258+
For an agent-created resource, the agent remains the acting provenance
259+
principal while its server-attested canonical human sponsor is the resource
260+
custodian. The agent receives no resource-owner or domain-admin inheritance,
261+
and sponsorship alone grants no resource permission.
262+
225263
### Host hooks for workflow plugins
226264

227265
Host hooks are the SDK seams for plugins that need to participate in the host

src/gateway/authorization/client-domain.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { describe, expect, it } from "vitest";
22
import type { GatewayClient } from "../server-methods/types.js";
33
import {
4+
bindGatewayClientAuthorizationDelegation,
45
bindGatewayClientAuthorizationDomain,
6+
getGatewayClientAuthorizationDelegation,
57
getGatewayClientAuthorizationDomain,
8+
inheritGatewayClientAuthorizationDelegation,
69
inheritGatewayClientAuthorizationDomain,
710
} from "./client-domain.js";
811

@@ -18,6 +21,13 @@ function client(): GatewayClient {
1821
};
1922
}
2023

24+
function serviceClient(): GatewayClient {
25+
return {
26+
...client(),
27+
principal: { issuer: "core", subject: "agent:main", kind: "service" },
28+
};
29+
}
30+
2131
describe("server-owned gateway client domain", () => {
2232
it("ignores plugin-visible object mutation", () => {
2333
const gatewayClient = client();
@@ -53,3 +63,60 @@ describe("server-owned gateway client domain", () => {
5363
expect(getGatewayClientAuthorizationDomain(target)).toEqual({ id: "domain-1" });
5464
});
5565
});
66+
67+
describe("server-owned gateway client delegation", () => {
68+
it("ignores plugin-visible mutation and rejects rebinding", () => {
69+
const gatewayClient = serviceClient();
70+
bindGatewayClientAuthorizationDomain(gatewayClient, { id: "domain-1" });
71+
bindGatewayClientAuthorizationDelegation(gatewayClient, {
72+
id: "delegation-1",
73+
assignmentId: "assignment-1",
74+
});
75+
76+
(
77+
gatewayClient as GatewayClient & {
78+
internal: { authorizationDelegation: { id: string; assignmentId: string } };
79+
}
80+
).internal = {
81+
authorizationDelegation: { id: "forged", assignmentId: "forged" },
82+
};
83+
84+
expect(getGatewayClientAuthorizationDelegation(gatewayClient)).toEqual({
85+
id: "delegation-1",
86+
assignmentId: "assignment-1",
87+
});
88+
expect(() =>
89+
bindGatewayClientAuthorizationDelegation(gatewayClient, {
90+
id: "delegation-2",
91+
assignmentId: "assignment-2",
92+
}),
93+
).toThrow(/already bound differently/i);
94+
});
95+
96+
it("requires a scoped service client and inherits only through the core helper", () => {
97+
const human = client();
98+
bindGatewayClientAuthorizationDomain(human, { id: "domain-1" });
99+
expect(() =>
100+
bindGatewayClientAuthorizationDelegation(human, {
101+
id: "delegation-1",
102+
assignmentId: "assignment-1",
103+
}),
104+
).toThrow(/service principal/i);
105+
106+
const source = serviceClient();
107+
const target = { ...source };
108+
bindGatewayClientAuthorizationDomain(source, { id: "domain-1" });
109+
bindGatewayClientAuthorizationDelegation(source, {
110+
id: "delegation-1",
111+
assignmentId: "assignment-1",
112+
});
113+
114+
expect(getGatewayClientAuthorizationDelegation(target)).toBeUndefined();
115+
inheritGatewayClientAuthorizationDomain(source, target);
116+
inheritGatewayClientAuthorizationDelegation(source, target);
117+
expect(getGatewayClientAuthorizationDelegation(target)).toEqual({
118+
id: "delegation-1",
119+
assignmentId: "assignment-1",
120+
});
121+
});
122+
});

src/gateway/authorization/client-domain.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import type { GatewayClient } from "../server-methods/types.js";
2-
import type { IsolationDomainRef } from "./contracts.js";
2+
import type { GatewayDelegationRef, IsolationDomainRef } from "./contracts.js";
33

44
const clientDomains = new WeakMap<GatewayClient, IsolationDomainRef>();
5+
const clientDelegations = new WeakMap<GatewayClient, GatewayDelegationRef>();
6+
7+
function requiredIdentifier(value: string, label: string): string {
8+
const normalized = value.trim();
9+
if (!normalized) {
10+
throw new Error(`${label} must be non-empty`);
11+
}
12+
return normalized;
13+
}
514

615
/** Binds a server-resolved tenant to a live client without exposing mutable authority on it. */
716
export function bindGatewayClientAuthorizationDomain(
@@ -36,3 +45,62 @@ export function inheritGatewayClientAuthorizationDomain(
3645
clientDomains.set(target, domain);
3746
}
3847
}
48+
49+
/** Binds a server-attested assignment to a scoped service client without exposing it on that client. */
50+
export function bindGatewayClientAuthorizationDelegation(
51+
client: GatewayClient,
52+
delegation: GatewayDelegationRef,
53+
): void {
54+
if (client.principal?.kind !== "service") {
55+
throw new Error("gateway client authorization delegation requires a service principal");
56+
}
57+
if (!clientDomains.has(client)) {
58+
throw new Error("gateway client authorization delegation requires a bound domain");
59+
}
60+
const canonical = Object.freeze({
61+
id: requiredIdentifier(delegation.id, "gateway client authorization delegation id"),
62+
assignmentId: requiredIdentifier(
63+
delegation.assignmentId,
64+
"gateway client authorization assignment id",
65+
),
66+
});
67+
const existing = clientDelegations.get(client);
68+
if (
69+
existing &&
70+
(existing.id !== canonical.id || existing.assignmentId !== canonical.assignmentId)
71+
) {
72+
throw new Error("gateway client authorization delegation is already bound differently");
73+
}
74+
clientDelegations.set(client, canonical);
75+
}
76+
77+
/** Returns only the server-owned assignment; plugin-visible client properties are ignored. */
78+
export function getGatewayClientAuthorizationDelegation(
79+
client: GatewayClient | null | undefined,
80+
): GatewayDelegationRef | undefined {
81+
return client ? clientDelegations.get(client) : undefined;
82+
}
83+
84+
/** Copies the private assignment only when core clones the same scoped service client. */
85+
export function inheritGatewayClientAuthorizationDelegation(
86+
source: GatewayClient,
87+
target: GatewayClient,
88+
): void {
89+
const delegation = clientDelegations.get(source);
90+
const sourcePrincipal = source.principal;
91+
const targetPrincipal = target.principal;
92+
if (!delegation) {
93+
return;
94+
}
95+
if (
96+
sourcePrincipal?.kind !== "service" ||
97+
targetPrincipal?.kind !== "service" ||
98+
sourcePrincipal.issuer !== targetPrincipal.issuer ||
99+
sourcePrincipal.subject !== targetPrincipal.subject ||
100+
getGatewayClientAuthorizationDomain(source)?.id !==
101+
getGatewayClientAuthorizationDomain(target)?.id
102+
) {
103+
throw new Error("gateway client authorization delegation cannot cross client identity");
104+
}
105+
clientDelegations.set(target, delegation);
106+
}

src/gateway/authorization/contracts.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,24 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
33

44
export type IsolationDomainRef = Readonly<{ id: string }>;
55

6+
export type GatewayDelegationRef = Readonly<{
7+
id: string;
8+
assignmentId: string;
9+
}>;
10+
11+
export type GatewayValidatedDelegation = GatewayDelegationRef &
12+
Readonly<{ sponsorPrincipalId: string }>;
13+
614
export type GatewayAuthorizationContext = Readonly<{
715
principalId: string;
16+
principalKind: GatewayPrincipal["kind"];
817
domain: IsolationDomainRef;
18+
method: string;
19+
permission: string;
20+
resources: readonly GatewayResourceRef[];
21+
pluginId?: string;
22+
requestId?: string;
23+
delegation?: GatewayValidatedDelegation;
924
}>;
1025

1126
export type GatewayResourceRef = Readonly<{
@@ -33,6 +48,7 @@ export type GatewayMethodAccessPolicy =
3348
export type GatewayAuthorizationRequest = Readonly<{
3449
principal: GatewayPrincipal;
3550
domain: IsolationDomainRef;
51+
delegation?: GatewayDelegationRef;
3652
method: string;
3753
permission: string;
3854
resources: readonly GatewayResourceRef[];
@@ -50,6 +66,7 @@ export type GatewayRbacDecision =
5066
allowed: true;
5167
principalId: string;
5268
domain: IsolationDomainRef;
69+
delegation?: GatewayValidatedDelegation;
5370
}>
5471
| Readonly<{
5572
allowed: false;

src/gateway/authorization/kernel.test.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,10 +206,115 @@ describe("gateway authorization kernel", () => {
206206
});
207207
expect(result).toEqual({
208208
allowed: true,
209-
security: { principalId: "principal-1", domain: { id: "domain-1" } },
209+
security: {
210+
principalId: "principal-1",
211+
principalKind: "human",
212+
domain: { id: "domain-1" },
213+
method: "sessions.get",
214+
permission: "session.read",
215+
resources: [resource],
216+
},
217+
});
218+
});
219+
220+
it("snapshots plugin action and resources before awaiting the provider", async () => {
221+
const mutableResource = { namespace: "core", type: "session", id: "session-original" };
222+
const policy = {
223+
kind: "resource" as const,
224+
permission: "session.read",
225+
resolveResources: () => [mutableResource],
226+
};
227+
let resolveDecision: (() => void) | undefined;
228+
const authorize = vi.fn(
229+
() =>
230+
new Promise<{
231+
allowed: true;
232+
principalId: string;
233+
domain: { id: string };
234+
}>((resolve) => {
235+
resolveDecision = () =>
236+
resolve({ allowed: true, principalId: "principal-1", domain: { id: "domain-1" } });
237+
}),
238+
);
239+
const pending = authorizeGatewayAccess({
240+
runtime: { mode: "isolated", authorize },
241+
policy,
242+
principal,
243+
method: "sessions.get",
244+
params: {},
245+
getConfig: () => config,
246+
});
247+
await vi.waitFor(() => expect(authorize).toHaveBeenCalledOnce());
248+
249+
mutableResource.id = "session-forged";
250+
policy.permission = "session.write";
251+
resolveDecision?.();
252+
253+
await expect(pending).resolves.toEqual({
254+
allowed: true,
255+
security: expect.objectContaining({
256+
permission: "session.read",
257+
resources: [{ namespace: "core", type: "session", id: "session-original" }],
258+
}),
210259
});
211260
});
212261

262+
it("snapshots the server delegation reference before awaiting the provider", async () => {
263+
const mutableDelegation = { id: "delegation-1", assignmentId: "assignment-1" };
264+
let resolveDecision: (() => void) | undefined;
265+
const authorize = vi.fn(
266+
() =>
267+
new Promise<{
268+
allowed: true;
269+
principalId: string;
270+
domain: { id: string };
271+
delegation: { id: string; assignmentId: string; sponsorPrincipalId: string };
272+
}>((resolve) => {
273+
resolveDecision = () =>
274+
resolve({
275+
allowed: true,
276+
principalId: "principal-agent",
277+
domain: { id: "domain-1" },
278+
delegation: {
279+
id: "delegation-1",
280+
assignmentId: "assignment-1",
281+
sponsorPrincipalId: "principal-owner",
282+
},
283+
});
284+
}),
285+
);
286+
const pending = authorizeGatewayAccess({
287+
runtime: { mode: "isolated", authorize },
288+
policy: resourcePolicy(() => [resource]),
289+
principal: { issuer: "core", subject: "agent:main", kind: "service" },
290+
delegation: mutableDelegation,
291+
method: "sessions.get",
292+
params: {},
293+
getConfig: () => config,
294+
});
295+
await vi.waitFor(() => expect(authorize).toHaveBeenCalledOnce());
296+
297+
mutableDelegation.id = "injected-delegation";
298+
mutableDelegation.assignmentId = "injected-assignment";
299+
resolveDecision?.();
300+
301+
await expect(pending).resolves.toEqual({
302+
allowed: true,
303+
security: expect.objectContaining({
304+
delegation: {
305+
id: "delegation-1",
306+
assignmentId: "assignment-1",
307+
sponsorPrincipalId: "principal-owner",
308+
},
309+
}),
310+
});
311+
expect(authorize).toHaveBeenCalledWith(
312+
expect.objectContaining({
313+
delegation: { id: "delegation-1", assignmentId: "assignment-1" },
314+
}),
315+
);
316+
});
317+
213318
it.each([
214319
{ principalId: "", domain: { id: "domain-1" } },
215320
{ principalId: "principal-1", domain: { id: " " } },

0 commit comments

Comments
 (0)