diff --git a/.changeset/resolver-permission-field.md b/.changeset/resolver-permission-field.md new file mode 100644 index 0000000000..be02d1e280 --- /dev/null +++ b/.changeset/resolver-permission-field.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add `permission` field to `createResolver` for declaring a resolver's access requirement, using the same `conditions`/`permit` policy notation as TailorDB's `.permission()` (restricted to `user` operands, e.g. `permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }]`). Rejects non-matching callers before `body` runs. Multiple policies combine like an allow-list with an explicit-deny override. `permission: "allowAnonymous"` explicitly documents that anonymous callers are allowed. Omitting `permission` keeps prior behavior unchanged. `tailor-sdk function test-run` enforces the same guard. diff --git a/packages/sdk/docs/services/resolver.md b/packages/sdk/docs/services/resolver.md index cdc6ac61fa..f4ff4076ef 100644 --- a/packages/sdk/docs/services/resolver.md +++ b/packages/sdk/docs/services/resolver.md @@ -350,7 +350,50 @@ createResolver({ }); ``` -## Authentication +## Permissions + +### Access Requirement (`permission`) + +By default, a resolver with no in-body check is reachable by an anonymous (unauthenticated) caller. Set `permission` to reject callers that don't match a condition, evaluated before `body` runs: + +```typescript +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "getMyOrders", + operation: "query", + permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], + output: t.object({ count: t.int() }), + body: async (context) => { + // context.user is guaranteed to be an authenticated caller here + return { count: 0 }; + }, +}); +``` + +`permission` uses the same `conditions`/`permit` notation as TailorDB's `.permission()` — an array of policies, restricted to `user` operands (a resolver has no associated record to compare against) with equality (`=`/`!=`) comparisons: + +- `{ user: "_loggedIn" }` — whether the caller is authenticated +- `{ user: "id" }` — the caller's user ID +- `{ user: "someAttribute" }` — any string or boolean attribute enabled in `auth.userProfile.attributes` (or `auth.machineUserAttributes` for machine users); array attributes aren't supported, since conditions only compare against a single string/boolean value + +Multiple conditions within the same policy's `conditions` array are combined with AND. `permit` is required. A `permit: false` policy always denies matching callers. With no `permit: true` policy, `permission` is a pure blocklist (everyone else is allowed); with at least one `permit: true` policy, it becomes an allow-list (denied by default, granted only by a matching `permit: true` policy). This lets you express different eligibility paths, e.g. allowing machine-user callers unconditionally while gating regular users behind a role check: + +```typescript +permission: [ + { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true }, + { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, +], +``` + +Besides a policy array, `permission` also accepts: + +- `"allowAnonymous"` — explicitly documents that anonymous callers are allowed. Behaves the same as omitting `permission`, but records the decision so it isn't mistaken for an oversight. +- Omitted (default) — unchanged: anonymous callers can still reach the resolver. + +This check is based on `context.user`, the original caller, so it still applies even when `authInvoker` swaps in a machine user for database access. + +### Running as a Machine User (`authInvoker`) Specify an `authInvoker` to execute the resolver with machine user credentials. Pass the machine user name as a plain string — it is type-narrowed to the names you defined in your auth config: diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6b8473f66f..60bed0e2a4 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -237,7 +237,7 @@ "tsdown": "0.22.7", "typescript": "6.0.3", "vitest": "4.1.10", - "zinfer": "0.2.5" + "zinfer": "0.2.7" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", diff --git a/packages/sdk/src/cli/commands/function/bundle.test.ts b/packages/sdk/src/cli/commands/function/bundle.test.ts index 14a0643075..d0dec4ac7e 100644 --- a/packages/sdk/src/cli/commands/function/bundle.test.ts +++ b/packages/sdk/src/cli/commands/function/bundle.test.ts @@ -217,6 +217,47 @@ export default { expect(result.bundledCode).toContain("machine_user"); expect(result.bundledCode).toContain(defaultWorkspaceId); }); + + test("injects the permission guard when the resolver has one", async () => { + const detected: DetectedFunction = { + type: "resolver", + name: "protected", + permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], + }; + const result = await bundle( + "resolver-permission.ts", + ` +export default { + operation: "query", + name: "protected", + body: () => 1, + output: { type: "integer", metadata: {} }, +}; +`, + detected, + ); + + expect(result.bundledCode).toContain("TailorErrorMessage"); + expect(result.bundledCode).toContain("access denied"); + }); + + test("does not inject a guard when permission is omitted", async () => { + const detected: DetectedFunction = { type: "resolver", name: "open" }; + const result = await bundle( + "resolver-open.ts", + ` +export default { + operation: "query", + name: "open", + body: () => 1, + output: { type: "integer", metadata: {} }, +}; +`, + detected, + ); + + expect(result.bundledCode).not.toContain("TailorErrorMessage"); + }); }); describe("executor", () => { diff --git a/packages/sdk/src/cli/commands/function/bundle.ts b/packages/sdk/src/cli/commands/function/bundle.ts index a5907f8741..bea36b9daf 100644 --- a/packages/sdk/src/cli/commands/function/bundle.ts +++ b/packages/sdk/src/cli/commands/function/bundle.ts @@ -18,7 +18,7 @@ import { composeFunctionTreeshakeOptions } from "#/cli/shared/function-treeshake import { resolveInlineSourcemap } from "#/cli/shared/inline-sourcemap"; import { platformBundleDefinePlugin } from "#/cli/shared/platform-bundle-plugin"; import { resolveTSConfigWithFallback } from "#/cli/shared/resolve-tsconfig"; -import { INVOKER_EXPR } from "#/cli/shared/runtime-exprs"; +import { buildResolverPermissionAndInputCheckExpr, INVOKER_EXPR } from "#/cli/shared/runtime-exprs"; import { assertDefined } from "#/utils/assert"; import ml from "#/utils/multiline"; import type { LogLevelInput } from "#/configure/config/types"; @@ -148,36 +148,22 @@ function generateEntry( `; case "resolver": { - // Mirrors the production resolver bundler (services/resolver/bundler.ts). + // Mirrors the production resolver bundler (services/resolver/bundler.ts): + // both call buildResolverPermissionAndInputCheckExpr so the permission + // guard and input validation can't drift between the two entry points. // In production, the operationHook injects user/env into context. // For test-run, we embed machine user info since there's no operationHook. const userExpr = buildMachineUserExpr(machineUser, workspaceId); + const guardAndInputCheckExpr = buildResolverPermissionAndInputCheckExpr(detected.permission); return ml /* js */ ` import _internalResolver from "${absoluteSourcePath}"; import { t } from "@tailor-platform/sdk"; - const _env = ${JSON.stringify(env)}; - const _user = ${userExpr}; - - const $tailor_resolver_body = async (context) => { - const _invoker = ${INVOKER_EXPR}; - if (_internalResolver.input) { - const result = t.object(_internalResolver.input).parse({ - value: context, - data: context, - user: _user, - }); - - if (result.issues) { - throw new TailorErrors(result.issues.map(issue => ({ - message: issue.message, - path: issue.path ?? [], - }))); - } - } - - const enrichedContext = { input: context, env: _env, user: _user, invoker: _invoker }; - return _internalResolver.body(enrichedContext); + const $tailor_resolver_body = async (rawInput) => { + const invoker = ${INVOKER_EXPR}; + const context = { input: rawInput, env: ${JSON.stringify(env)}, user: ${userExpr}, invoker }; + ${guardAndInputCheckExpr} + return _internalResolver.body(context); }; export { $tailor_resolver_body as main }; diff --git a/packages/sdk/src/cli/commands/function/detect.test.ts b/packages/sdk/src/cli/commands/function/detect.test.ts index 9d1e924c16..55d5d5ef19 100644 --- a/packages/sdk/src/cli/commands/function/detect.test.ts +++ b/packages/sdk/src/cli/commands/function/detect.test.ts @@ -55,6 +55,26 @@ export default { expect(result.name).toBe("my-resolver"); }); + test("carries the resolver's `permission` config through for test-run to enforce", async () => { + const filePath = writeFile( + "resolver-permission.mjs", + ` +export default { + operation: "query", + name: "protected", + permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], + body: (ctx) => ctx.input, + output: { type: "string", metadata: {}, fields: {} }, +}; +`, + ); + + const result = await detectFunctionType({ filePath }); + expect(result.permission).toEqual([ + { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + ]); + }); + test("builds a working local input parser from real t.* fields", async () => { const configureTypesUrl = pathToFileURL( path.join(__dirname, "../../../configure/types/index.ts"), diff --git a/packages/sdk/src/cli/commands/function/detect.ts b/packages/sdk/src/cli/commands/function/detect.ts index ba90bac741..cdbd372ee5 100644 --- a/packages/sdk/src/cli/commands/function/detect.ts +++ b/packages/sdk/src/cli/commands/function/detect.ts @@ -13,6 +13,7 @@ import { WorkflowJobSchema } from "#/parser/service/workflow/index"; import { type FieldRuntime, parseInputFields } from "#/runtime/field-parse"; import { assertDefined } from "#/utils/assert"; import type { TailorUser } from "#/runtime/types"; +import type { Resolver } from "#/types/resolver.generated"; export type FunctionType = "resolver" | "executor" | "workflow-job" | "plain"; @@ -42,6 +43,8 @@ export interface DetectedFunction { hasInput?: boolean; /** For resolvers with input: pre-built schema object with .parse() for local format detection */ inputSchema?: InputSchema; + /** For resolvers: the resolver's `permission` config, enforced the same way as production */ + permission?: Resolver["permission"]; } interface DetectFunctionOptions { @@ -83,6 +86,7 @@ export async function detectFunctionType( name: resolverResult.data.name, hasInput: rawInput != null, inputSchema, + permission: resolverResult.data.permission, }; } diff --git a/packages/sdk/src/cli/services/resolver/bundler.test.ts b/packages/sdk/src/cli/services/resolver/bundler.test.ts index e181ed4a91..dc3c68d451 100644 --- a/packages/sdk/src/cli/services/resolver/bundler.test.ts +++ b/packages/sdk/src/cli/services/resolver/bundler.test.ts @@ -120,6 +120,62 @@ describe("bundleResolvers", () => { ).resolves.toEqual(new Map()); }); + test("injects the permission guard into the entry file", async () => { + using tmp = tempCwd("sdk-bundler-permission-"); + const resolverDir = path.join(tmp.dir, "src/backend/permissioncheck/resolver"); + fs.mkdirSync(resolverDir, { recursive: true }); + fs.writeFileSync( + path.join(resolverDir, "protected.ts"), + `export default {\n` + + ` operation: "query",\n` + + ` name: "protected",\n` + + ` permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }],\n` + + ` body: async () => 1,\n` + + ` output: { type: "integer", metadata: {}, fields: {} },\n` + + `};\n`, + ); + + const result = await bundleResolvers( + "permissioncheck", + { files: ["./src/backend/permissioncheck/resolver/*.ts"] }, + tmp.dir, + ); + + const entryContent = result.get("protected"); + + expect(entryContent).toBeDefined(); + expect(entryContent).toContain("user.type!=="); + expect(entryContent).toContain("TailorErrorMessage"); + expect(entryContent).toContain("access denied"); + }); + + test("does not inject a guard when permission is omitted or allowAnonymous", async () => { + using tmp = tempCwd("sdk-bundler-nopermission-"); + const resolverDir = path.join(tmp.dir, "src/backend/nopermission/resolver"); + fs.mkdirSync(resolverDir, { recursive: true }); + fs.writeFileSync( + path.join(resolverDir, "open.ts"), + `export default {\n` + + ` operation: "query",\n` + + ` name: "open",\n` + + ` permission: "allowAnonymous",\n` + + ` body: async () => 1,\n` + + ` output: { type: "integer", metadata: {}, fields: {} },\n` + + `};\n`, + ); + + const result = await bundleResolvers( + "nopermission", + { files: ["./src/backend/nopermission/resolver/*.ts"] }, + tmp.dir, + ); + + const entryContent = result.get("open"); + + expect(entryContent).toBeDefined(); + expect(entryContent).not.toContain("TailorErrorMessage"); + }); + test("resolves tsconfig relative to baseDir, not process.cwd()", async () => { using _tmp = tempCwd("sdk-bundler-tsconfig-"); const otherDir = fs.mkdtempSync(path.join(os.tmpdir(), "sdk-bundler-tsconfig-other-")); diff --git a/packages/sdk/src/cli/services/resolver/bundler.ts b/packages/sdk/src/cli/services/resolver/bundler.ts index fbaa6fd557..00bce4e0ca 100644 --- a/packages/sdk/src/cli/services/resolver/bundler.ts +++ b/packages/sdk/src/cli/services/resolver/bundler.ts @@ -9,16 +9,18 @@ import { composeFunctionTreeshakeOptions } from "#/cli/shared/function-treeshake import { logger, styles } from "#/cli/shared/logger"; import { platformBundleDefinePlugin } from "#/cli/shared/platform-bundle-plugin"; import { resolveTSConfigWithFallback } from "#/cli/shared/resolve-tsconfig"; -import { INVOKER_EXPR } from "#/cli/shared/runtime-exprs"; +import { buildResolverPermissionAndInputCheckExpr, INVOKER_EXPR } from "#/cli/shared/runtime-exprs"; import { serializeTriggerContext, type TriggerContext } from "#/cli/shared/trigger-context"; import { createVirtualEntry } from "#/cli/shared/virtual-entry"; import ml from "#/utils/multiline"; import { loadResolver } from "./loader"; import type { LogLevel } from "#/configure/config/types"; +import type { Resolver } from "#/types/resolver.generated"; interface ResolverInfo { name: string; sourceFile: string; + permission: Resolver["permission"]; } /** @@ -71,6 +73,7 @@ export async function bundleResolvers( resolvers.push({ name: resolver.name, sourceFile: file, + permission: resolver.permission, }); } @@ -127,6 +130,7 @@ async function bundleSingleResolver( contextHash, async build(cachePlugins) { const absoluteSourcePath = path.resolve(resolver.sourceFile); + const guardAndInputCheckExpr = buildResolverPermissionAndInputCheckExpr(resolver.permission); const entryContent = ml /* js */ ` import _internalResolver from "${absoluteSourcePath}"; @@ -134,21 +138,7 @@ async function bundleSingleResolver( const $tailor_resolver_body = async (context) => { const invoker = ${INVOKER_EXPR}; - if (_internalResolver.input) { - const result = t.object(_internalResolver.input).parse({ - value: context.input, - data: context.input, - user: context.user, - }); - - if (result.issues) { - throw new TailorErrors(result.issues.map(issue => ({ - message: issue.message, - path: issue.path ?? [], - }))); - } - } - + ${guardAndInputCheckExpr} return _internalResolver.body({ ...context, invoker }); }; diff --git a/packages/sdk/src/cli/shared/runtime-exprs.test.ts b/packages/sdk/src/cli/shared/runtime-exprs.test.ts index 6a8e0ce488..a9178b291d 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.test.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.test.ts @@ -1,5 +1,9 @@ import { describe, test, expect } from "vitest"; -import { buildExecutorArgsExpr, buildResolverOperationHookExpr } from "./runtime-exprs"; +import { + buildExecutorArgsExpr, + buildResolverOperationHookExpr, + buildResolverPermissionGuardExpr, +} from "./runtime-exprs"; describe("buildExecutorArgsExpr", () => { const env = { API_URL: "https://example.com", DEBUG: true }; @@ -103,3 +107,200 @@ describe("buildResolverOperationHookExpr", () => { expect(expr).toContain("env: {}"); }); }); + +describe("buildResolverPermissionGuardExpr", () => { + class TailorErrorMessage extends Error {} + + function runGuard( + permission: Parameters[0], + user: unknown, + ): void { + const guard = buildResolverPermissionGuardExpr(permission); + if (!guard) { + return; + } + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func + const fn = new Function("context", "TailorErrorMessage", guard); + fn({ user }, TailorErrorMessage); + } + + test("returns undefined when permission is omitted", () => { + expect(buildResolverPermissionGuardExpr(undefined)).toBeUndefined(); + }); + + test("returns undefined when permission is allowAnonymous", () => { + expect(buildResolverPermissionGuardExpr("allowAnonymous")).toBeUndefined(); + }); + + test("_loggedIn permit:true allows an authenticated user", () => { + const permission = [ + { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + ] as const; + expect(() => runGuard(permission, { type: "user" })).not.toThrow(); + }); + + test("_loggedIn permit:true rejects an anonymous user", () => { + const permission = [ + { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + ] as const; + expect(() => runGuard(permission, { type: "" })).toThrow(TailorErrorMessage); + }); + + test("permit:false denies matching callers instead of allowing them", () => { + const permission = [ + { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: false }, + ] as const; + expect(() => runGuard(permission, { type: "user" })).toThrow(TailorErrorMessage); + expect(() => runGuard(permission, { type: "" })).not.toThrow(); + }); + + test("supports the != operator", () => { + const permission = [ + { conditions: [[{ user: "role" }, "!=", "BANNED"]], permit: true }, + ] as const; + expect(() => runGuard(permission, { attributes: { role: "MEMBER" } })).not.toThrow(); + expect(() => runGuard(permission, { attributes: { role: "BANNED" } })).toThrow( + TailorErrorMessage, + ); + }); + + test("!= does not let a caller with no such attribute at all through", () => { + // A missing attribute must not satisfy `!=` -- otherwise an + // attribute-less caller would unintentionally match a policy meant to + // exclude only a specific value. + const permission = [ + { conditions: [[{ user: "role" }, "!=", "BANNED"]], permit: true }, + ] as const; + expect(() => runGuard(permission, { attributes: null })).toThrow(TailorErrorMessage); + expect(() => runGuard(permission, { attributes: {} })).toThrow(TailorErrorMessage); + }); + + test("supports the id operand", () => { + const permission = [ + { + conditions: [[{ user: "id" }, "=", "11111111-1111-1111-1111-111111111111"]], + permit: true, + }, + ] as const; + expect(() => + runGuard(permission, { id: "11111111-1111-1111-1111-111111111111" }), + ).not.toThrow(); + expect(() => runGuard(permission, { id: "other" })).toThrow(TailorErrorMessage); + }); + + test("supports arbitrary user attribute operands", () => { + const permission = [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }] as const; + expect(() => runGuard(permission, { attributes: { role: "ADMIN" } })).not.toThrow(); + expect(() => runGuard(permission, { attributes: { role: "MEMBER" } })).toThrow( + TailorErrorMessage, + ); + }); + + test("ANDs multiple conditions within a policy", () => { + const permission = [ + { + conditions: [ + [{ user: "_loggedIn" }, "=", true], + [{ user: "role" }, "=", "ADMIN"], + ], + permit: true, + }, + ] as const; + expect(() => + runGuard(permission, { type: "user", attributes: { role: "ADMIN" } }), + ).not.toThrow(); + expect(() => runGuard(permission, { type: "user", attributes: { role: "MEMBER" } })).toThrow( + TailorErrorMessage, + ); + expect(() => runGuard(permission, { type: "", attributes: { role: "ADMIN" } })).toThrow( + TailorErrorMessage, + ); + }); + + test("ORs multiple allow policies", () => { + // Allow machine-user callers unconditionally, or regular users with role ADMIN + const permission = [ + { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true }, + { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, + ] as const; + expect(() => + runGuard(permission, { attributes: { isServiceAccount: true, role: "MEMBER" } }), + ).not.toThrow(); + expect(() => + runGuard(permission, { attributes: { isServiceAccount: false, role: "ADMIN" } }), + ).not.toThrow(); + expect(() => + runGuard(permission, { attributes: { isServiceAccount: false, role: "MEMBER" } }), + ).toThrow(TailorErrorMessage); + }); + + test("a deny policy overrides a matching allow policy", () => { + const permission = [ + { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + { conditions: [[{ user: "role" }, "=", "BANNED"]], permit: false }, + ] as const; + expect(() => + runGuard(permission, { type: "user", attributes: { role: "MEMBER" } }), + ).not.toThrow(); + expect(() => runGuard(permission, { type: "user", attributes: { role: "BANNED" } })).toThrow( + TailorErrorMessage, + ); + }); + + test("denies by default when no allow policy matches", () => { + const permission = [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }] as const; + expect(() => runGuard(permission, { attributes: { role: "GUEST" } })).toThrow( + TailorErrorMessage, + ); + }); + + test("includes description in the thrown message when present", () => { + const permission = [ + { + conditions: [[{ user: "_loggedIn" }, "=", true]], + permit: true, + description: "must be logged in", + }, + ] as const; + expect(() => runGuard(permission, { type: "" })).toThrow(/must be logged in/); + }); + + test("only includes the description of the policy that actually caused the denial", () => { + const permission = [ + { + conditions: [[{ user: "_loggedIn" }, "=", true]], + permit: true, + description: "must be logged in", + }, + { + conditions: [[{ user: "role" }, "=", "BANNED"]], + permit: false, + description: "banned users are rejected", + }, + ] as const; + + // Denied for failing the allow-list (not logged in) — only that policy's + // description should appear, not the unrelated deny policy's. + expect(() => runGuard(permission, { type: "" })).toThrow("access denied: must be logged in"); + + // Denied for matching the deny policy (logged in but banned) — only that + // policy's description should appear, not the unrelated allow policy's. + expect(() => runGuard(permission, { type: "user", attributes: { role: "BANNED" } })).toThrow( + "access denied: banned users are rejected", + ); + + // Allowed: logged in and not banned. + expect(() => + runGuard(permission, { type: "user", attributes: { role: "MEMBER" } }), + ).not.toThrow(); + }); + + test("throws at bundle time on an empty permission array (schema should reject this, but guard defensively too)", () => { + expect(() => buildResolverPermissionGuardExpr([])).toThrow(/at least one policy/); + }); + + test("throws at bundle time on a policy with an empty conditions array (schema should reject this, but guard defensively too)", () => { + const permission = [{ conditions: [], permit: true }] as const; + expect(() => buildResolverPermissionGuardExpr(permission)).toThrow(/at least one condition/); + }); +}); diff --git a/packages/sdk/src/cli/shared/runtime-exprs.ts b/packages/sdk/src/cli/shared/runtime-exprs.ts index 5e50425638..0b526522b2 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.ts @@ -12,6 +12,7 @@ */ import { tailorUserMap } from "#/parser/service/tailordb/index"; import type { Trigger } from "#/types/executor.generated"; +import type { Resolver } from "#/types/resolver.generated"; // --------------------------------------------------------------------------- // Bundle inline @@ -102,3 +103,163 @@ export function buildResolverOperationHookExpr( ): string { return `({ ...context.pipeline, input: context.args, user: ${tailorUserMap}, env: ${JSON.stringify(env)} });`; } + +type ResolverPermissionPolicies = Extract, readonly unknown[]>; +type ResolverPermissionPolicy = ResolverPermissionPolicies[number]; +type ResolverPermissionOperand = string | boolean | { user: string }; +type ResolverPermissionCondition = readonly [ + ResolverPermissionOperand, + "=" | "!=", + ResolverPermissionOperand, +]; + +function isSingleResolverCondition( + conditions: ResolverPermissionPolicy["conditions"], +): conditions is ResolverPermissionCondition { + return conditions.length === 3 && typeof conditions[1] === "string"; +} + +function resolverPermissionOperandExpr(operand: ResolverPermissionOperand): string { + if (typeof operand === "object") { + if (operand.user === "_loggedIn") { + return `(context.user.type !== "")`; + } + if (operand.user === "id") { + return `context.user.id`; + } + return `context.user.attributes?.[${JSON.stringify(operand.user)}]`; + } + return JSON.stringify(operand); +} + +// `_loggedIn` always evaluates to a defined boolean, and `id` is always a +// defined string (a nil UUID for unauthenticated callers), but an arbitrary +// attribute lookup (`context.user.attributes?.[key]`) can be `undefined` -- +// either because the whole attribute map is null or the key isn't set. +function isArbitraryAttributeOperand(operand: ResolverPermissionOperand): boolean { + return typeof operand === "object" && operand.user !== "_loggedIn" && operand.user !== "id"; +} + +function resolverPermissionConditionExpr(condition: ResolverPermissionCondition): string { + const [left, operator, right] = condition; + const leftExpr = resolverPermissionOperandExpr(left); + const rightExpr = resolverPermissionOperandExpr(right); + + if (operator === "!=") { + // A missing attribute must not satisfy `!=` -- otherwise an + // attribute-less caller would unintentionally match a policy meant to + // exclude only a specific value (`{ user: "role" } != "BANNED"` should + // not let a caller with no `role` attribute at all through). + const userOperandExpr = isArbitraryAttributeOperand(left) + ? leftExpr + : isArbitraryAttributeOperand(right) + ? rightExpr + : undefined; + if (userOperandExpr) { + return `(${userOperandExpr} !== undefined && ${leftExpr} !== ${rightExpr})`; + } + } + + const jsOperator = operator === "=" ? "===" : "!=="; + return `(${leftExpr} ${jsOperator} ${rightExpr})`; +} + +function resolverPermissionPolicyExpr(policy: ResolverPermissionPolicy): string { + const conditions = isSingleResolverCondition(policy.conditions) + ? [policy.conditions] + : policy.conditions; + if (conditions.length === 0) { + throw new Error( + "Resolver permission policy must have at least one condition, got an empty array.", + ); + } + return conditions.map(resolverPermissionConditionExpr).join(" && "); +} + +/** + * Build a JS object literal capturing whether `policy` matched and its + * (possibly empty) description, for runtime denial-reason attribution. + * @param policy - The policy to compile + * @returns A JS object literal expression: `{ matched, description }` + */ +function policyEntryExpr(policy: ResolverPermissionPolicy): string { + return `{ matched: ${resolverPermissionPolicyExpr(policy)}, description: ${JSON.stringify(policy.description ?? "")} }`; +} + +/** + * Build the permission guard statement injected at resolver entry. + * + * Rejects the call with `TailorErrorMessage` when the caller doesn't match + * `permission`, evaluated against `context.user` — the original caller, + * unaffected by `authInvoker`. A `permit: false` policy always denies matching + * callers. With no `permit: true` policy, `permission` is a pure blocklist + * (everyone else is allowed); with at least one, it's an allow-list (deny by + * default, granted only by a matching `permit: true` policy). The thrown + * message only includes the description(s) of the policy/policies that + * actually caused the denial. + * @param permission - The resolver's `permission` config + * @returns A JS statement, or `undefined` when `permission` is omitted or `"allowAnonymous"` + */ +export function buildResolverPermissionGuardExpr( + permission: Resolver["permission"], +): string | undefined { + if (!permission || permission === "allowAnonymous") { + return undefined; + } + if (permission.length === 0) { + throw new Error("Resolver permission must have at least one policy, got an empty array."); + } + const denyPolicies = permission.filter((policy) => policy.permit === false); + const allowPolicies = permission.filter((policy) => policy.permit !== false); + + const denyEntriesExpr = `[${denyPolicies.map(policyEntryExpr).join(", ")}]`; + const allowEntriesExpr = `[${allowPolicies.map(policyEntryExpr).join(", ")}]`; + + return `{ + const $denyPolicies = ${denyEntriesExpr}; + const $allowPolicies = ${allowEntriesExpr}; + const $matchedDeny = $denyPolicies.filter((p) => p.matched); + const $anyAllowMatched = $allowPolicies.some((p) => p.matched); + if ($matchedDeny.length > 0 || ($allowPolicies.length > 0 && !$anyAllowMatched)) { + const $reasons = ($matchedDeny.length > 0 ? $matchedDeny : $allowPolicies) + .map((p) => p.description) + .filter(Boolean); + throw new TailorErrorMessage($reasons.length > 0 ? "access denied: " + $reasons.join("; ") : "access denied"); + } + }`; +} + +/** + * Build the permission guard and input-validation statements shared by every + * resolver entry wrapper (production bundling and `function test-run`). + * + * Kept as a single generator so a resolver-wrapping behavior (like the + * permission guard) can't be added to one entry-point template and forgotten + * in the other. References `context.user`, `context.input`, and + * `_internalResolver` — the caller's wrapper must bind a `context` object + * with `user`/`input` properties before inlining this expression. + * @param permission - The resolver's `permission` config + * @returns A JS statement block to inline before calling `_internalResolver.body(...)` + */ +export function buildResolverPermissionAndInputCheckExpr( + permission: Resolver["permission"], +): string { + const permissionGuardExpr = buildResolverPermissionGuardExpr(permission); + return ` + ${permissionGuardExpr ?? ""} + if (_internalResolver.input) { + const result = t.object(_internalResolver.input).parse({ + value: context.input, + data: context.input, + user: context.user, + }); + + if (result.issues) { + throw new TailorErrors(result.issues.map(issue => ({ + message: issue.message, + path: issue.path ?? [], + }))); + } + } + `; +} diff --git a/packages/sdk/src/configure/services/idp/permission.ts b/packages/sdk/src/configure/services/idp/permission.ts index 679750708e..f9defcc82d 100644 --- a/packages/sdk/src/configure/services/idp/permission.ts +++ b/packages/sdk/src/configure/services/idp/permission.ts @@ -1,41 +1,15 @@ +import type { + UserBooleanArrayOperand, + UserBooleanOperand, + UserStringArrayOperand, + UserStringOperand, +} from "#/configure/types/permission-operand.types"; import type { IdPUserField } from "#/parser/service/idp/types"; import type { InferredAttributeMap } from "#/runtime/types"; type EqualityOperator = "=" | "!="; type ContainsOperator = "in" | "not in"; -type StringFieldKeys = { - [K in keyof User]: User[K] extends string ? K : never; -}[keyof User]; - -type StringArrayFieldKeys = { - [K in keyof User]: User[K] extends string[] ? K : never; -}[keyof User]; - -type BooleanFieldKeys = { - [K in keyof User]: User[K] extends boolean ? K : never; -}[keyof User]; - -type BooleanArrayFieldKeys = { - [K in keyof User]: User[K] extends boolean[] ? K : never; -}[keyof User]; - -type UserStringOperand = { - user: StringFieldKeys | "id"; -}; - -type UserStringArrayOperand = { - user: StringArrayFieldKeys; -}; - -type UserBooleanOperand = { - user: BooleanFieldKeys | "_loggedIn"; -}; - -type UserBooleanArrayOperand = { - user: BooleanArrayFieldKeys; -}; - type IdPUserOperand = Update extends true ? { oldIdpUser: IdPUserField } | { newIdpUser: IdPUserField } : { idpUser: IdPUserField }; diff --git a/packages/sdk/src/configure/services/resolver/index.ts b/packages/sdk/src/configure/services/resolver/index.ts index a66973b79a..3a87f99be5 100644 --- a/packages/sdk/src/configure/services/resolver/index.ts +++ b/packages/sdk/src/configure/services/resolver/index.ts @@ -2,6 +2,11 @@ export { createResolver } from "./resolver"; export type { Resolver } from "#/types/resolver.generated"; export type { QueryType } from "./types"; +export type { + ResolverPermission, + ResolverPermissionCondition, + ResolverPermissionPolicy, +} from "./permission"; export type { ResolverServiceConfig, ResolverExternalConfig, diff --git a/packages/sdk/src/configure/services/resolver/permission.ts b/packages/sdk/src/configure/services/resolver/permission.ts new file mode 100644 index 0000000000..60e2aeddd5 --- /dev/null +++ b/packages/sdk/src/configure/services/resolver/permission.ts @@ -0,0 +1,68 @@ +import type { + UserBooleanOperand, + UserStringOperand, +} from "#/configure/types/permission-operand.types"; +import type { InferredAttributeMap } from "#/runtime/types"; + +type EqualityOperator = "=" | "!="; + +type StringEqualityCondition = + | readonly [UserStringOperand, EqualityOperator, string] + | readonly [string, EqualityOperator, UserStringOperand]; + +type BooleanEqualityCondition = + | readonly [UserBooleanOperand, EqualityOperator, boolean] + | readonly [boolean, EqualityOperator, UserBooleanOperand]; + +/** + * A single condition for {@link ResolverPermission}. + * + * Only `user` operands are supported (unlike TailorDB's `record`/`newRecord`/ + * `oldRecord` operands) — a resolver has no associated record to compare + * against. Only equality (`=`/`!=`) is supported for now. + * + * The User type is extended by `tailor.d.ts`, which is automatically generated + * when running `tailor-sdk generate`. Attributes enabled in the config file's + * `auth.userProfile.attributes` (or `auth.machineUserAttributes` when + * `userProfile` is omitted) become available as types. + */ +export type ResolverPermissionCondition = + | StringEqualityCondition + | BooleanEqualityCondition; + +/** + * A single access policy, in the same style as TailorDB's `.permission()` + * policies. + */ +export type ResolverPermissionPolicy = { + conditions: ResolverPermissionCondition | readonly ResolverPermissionCondition[]; + /** Whether matching callers are granted (`true`) or denied (`false`) access. */ + permit: boolean; + description?: string; +}; + +/** + * Access requirement for a resolver, evaluated against the original caller + * (`context.user`) before `body` runs — unaffected by `authInvoker`. + * + * A `permit: false` policy always denies matching callers. With no + * `permit: true` policy, this is a pure blocklist (everyone else is allowed); + * with at least one, it's an allow-list (deny by default, granted only by a + * matching `permit: true` policy). `"allowAnonymous"` explicitly documents + * that anonymous callers are allowed. + * @example + * const permission: ResolverPermission = [ + * { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + * ]; + * @example + * // Allow machine-user callers unconditionally, gate regular users behind a role + * const permission: ResolverPermission = [ + * { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true }, + * { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, + * ]; + * @example + * const permission: ResolverPermission = "allowAnonymous"; + */ +export type ResolverPermission = + | readonly ResolverPermissionPolicy[] + | "allowAnonymous"; diff --git a/packages/sdk/src/configure/services/resolver/resolver.test.ts b/packages/sdk/src/configure/services/resolver/resolver.test.ts index 063c1e19cb..4b0a184f99 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.test.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.test.ts @@ -5,6 +5,7 @@ import { createResolver } from "./resolver"; import type { TailorInvoker, TailorUser } from "#/runtime/types"; import type { output } from "#/types/helpers"; import type { ResolverInput } from "#/types/resolver.generated"; +import type { ResolverPermission } from "./permission"; describe("createResolver", () => { describe("type inference", () => { @@ -465,6 +466,41 @@ describe("createResolver", () => { expect(resolver.authInvoker).toEqual({ namespace: "my-auth", machineUserName: "batch-user" }); }); + test("creates resolver with loggedIn permission condition", () => { + const outputType = t.object({ result: t.string() }); + + const resolver = createResolver({ + name: "withPermissionLoggedIn", + operation: "query", + output: outputType, + body: () => ({ result: "ok" }), + permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], + }); + + expect(resolver.permission).toEqual([ + { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + ]); + }); + + test("creates resolver with permission: allowAnonymous", () => { + const outputType = t.object({ result: t.string() }); + + const resolver = createResolver({ + name: "withPermissionAllowAnonymous", + operation: "query", + output: outputType, + body: () => ({ result: "ok" }), + permission: "allowAnonymous", + }); + + expect(resolver.permission).toBe("allowAnonymous"); + }); + + test("ResolverPermission type accepts a standalone allowAnonymous constant", () => { + const permission: ResolverPermission = "allowAnonymous"; + expect(permission).toBe("allowAnonymous"); + }); + test("creates minimal resolver without optional fields", () => { const outputType = t.object({ result: t.string() }); @@ -480,6 +516,7 @@ describe("createResolver", () => { expect(resolver.output).toBe(outputType); expect(resolver.description).toBeUndefined(); expect(resolver.input).toBeUndefined(); + expect(resolver.permission).toBeUndefined(); }); test("accepts Record as output and converts to t.object()", () => { diff --git a/packages/sdk/src/configure/services/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index 24db05630c..8c27dae87a 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -1,6 +1,7 @@ import { t, type TailorAnyField, type TailorField } from "#/configure/types/type"; import { brandValue } from "#/utils/brand"; import type { AuthInvoker } from "#/configure/services/auth/index"; +import type { ResolverPermission } from "#/configure/services/resolver/permission"; import type { MachineUserName } from "#/configure/types/machine-user"; import type { TailorEnv, TailorInvoker, TailorUser } from "#/runtime/types"; import type { InferFieldsOutput, output } from "#/types/helpers"; @@ -35,12 +36,13 @@ type NormalizedOutput | undefined, Output extends TailorAnyField | Record, -> = Omit & +> = Omit & Readonly<{ input?: Input; output: NormalizedOutput; body: (context: Context) => OutputType | Promise>; authInvoker?: AuthInvoker | MachineUserName; + permission?: ResolverPermission; }>; /** @@ -57,6 +59,14 @@ type ResolverReturn< * If not specified, this is automatically set to true when an executor uses this resolver * with `resolverExecutedTrigger`. If explicitly set to false while an executor uses this * resolver, an error will be thrown during apply. + * + * `permission` declares the resolver's access requirement, checked against `context.user` (the + * original caller, unaffected by `authInvoker`) before `body` runs. Omitted (default): + * unchanged, anonymous callers can reach the resolver. `"allowAnonymous"`: explicitly documents + * that anonymous callers are allowed. An array of `{ conditions, permit }` policies (in the same + * style as TailorDB's `.permission()`) rejects non-matching callers: with no `permit: true` + * policy it's a blocklist (only `permit: false` matches are denied), with at least one it's + * an allow-list (denied unless a `permit: true` policy matches). * @template Input * @template Output * @param config - Resolver configuration @@ -67,6 +77,7 @@ type ResolverReturn< * export default createResolver({ * name: "getUser", * operation: "query", + * permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], * input: { * id: t.string(), * }, @@ -86,12 +97,13 @@ export function createResolver< Input extends Record | undefined = undefined, Output extends TailorAnyField | Record = TailorAnyField, >( - config: Omit & + config: Omit & Readonly<{ input?: Input; output: Output; body: (context: Context) => OutputType | Promise>; authInvoker?: AuthInvoker | MachineUserName; + permission?: ResolverPermission; }>, ): ResolverReturn { // Check if output is already a TailorField using duck typing. diff --git a/packages/sdk/src/configure/services/tailordb/permission.ts b/packages/sdk/src/configure/services/tailordb/permission.ts index 5562ecebb1..bc70574ee2 100644 --- a/packages/sdk/src/configure/services/tailordb/permission.ts +++ b/packages/sdk/src/configure/services/tailordb/permission.ts @@ -1,3 +1,9 @@ +import type { + UserBooleanArrayOperand, + UserBooleanOperand, + UserStringArrayOperand, + UserStringOperand, +} from "#/configure/types/permission-operand.types"; import type { InferredAttributeMap } from "#/runtime/types"; // --- Permission types (UX-focused, for configure layer) --- @@ -74,39 +80,6 @@ type EqualityOperator = "=" | "!="; type ContainsOperator = "in" | "not in"; type HasAnyOperator = "hasAny" | "not hasAny"; -// Helper types for User field extraction -type StringFieldKeys = { - [K in keyof User]: User[K] extends string ? K : never; -}[keyof User]; - -type StringArrayFieldKeys = { - [K in keyof User]: User[K] extends string[] ? K : never; -}[keyof User]; - -type BooleanFieldKeys = { - [K in keyof User]: User[K] extends boolean ? K : never; -}[keyof User]; - -type BooleanArrayFieldKeys = { - [K in keyof User]: User[K] extends boolean[] ? K : never; -}[keyof User]; - -type UserStringOperand = { - user: StringFieldKeys | "id"; -}; - -type UserStringArrayOperand = { - user: StringArrayFieldKeys; -}; - -type UserBooleanOperand = { - user: BooleanFieldKeys | "_loggedIn"; -}; - -type UserBooleanArrayOperand = { - user: BooleanArrayFieldKeys; -}; - type RecordOperand = Update extends true ? { oldRecord: (keyof Type & string) | "id" } | { newRecord: (keyof Type & string) | "id" } : { record: (keyof Type & string) | "id" }; diff --git a/packages/sdk/src/configure/types/permission-operand.types.ts b/packages/sdk/src/configure/types/permission-operand.types.ts new file mode 100644 index 0000000000..52719a4567 --- /dev/null +++ b/packages/sdk/src/configure/types/permission-operand.types.ts @@ -0,0 +1,41 @@ +// Shared generic operand-key extraction helpers for permission-condition +// systems (TailorDB `.permission()`/`.gqlPermission()`, IdP `permission`, +// resolver `permission`). Each system's `User` type shape differs, but the +// "which keys of `User` hold a string/boolean (array)?" derivation is +// identical, so it lives here once instead of being copied per service. +// +// This is a pure type module: type declarations only, no zod/schema +// references, importable type-only from any layer. +import type { InferredAttributeMap } from "#/runtime/types"; + +export type StringFieldKeys = { + [K in keyof User]: User[K] extends string ? K : never; +}[keyof User]; + +export type StringArrayFieldKeys = { + [K in keyof User]: User[K] extends string[] ? K : never; +}[keyof User]; + +export type BooleanFieldKeys = { + [K in keyof User]: User[K] extends boolean ? K : never; +}[keyof User]; + +export type BooleanArrayFieldKeys = { + [K in keyof User]: User[K] extends boolean[] ? K : never; +}[keyof User]; + +export type UserStringOperand = { + user: StringFieldKeys | "id"; +}; + +export type UserStringArrayOperand = { + user: StringArrayFieldKeys; +}; + +export type UserBooleanOperand = { + user: BooleanFieldKeys | "_loggedIn"; +}; + +export type UserBooleanArrayOperand = { + user: BooleanArrayFieldKeys; +}; diff --git a/packages/sdk/src/parser/service/resolver/schema.test.ts b/packages/sdk/src/parser/service/resolver/schema.test.ts new file mode 100644 index 0000000000..8f39fc7bdd --- /dev/null +++ b/packages/sdk/src/parser/service/resolver/schema.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "vitest"; +import { ResolverPermissionSchema } from "./schema"; + +describe("ResolverPermissionSchema", () => { + test("accepts a single policy with one condition", () => { + expect(() => + ResolverPermissionSchema.parse([ + { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + ]), + ).not.toThrow(); + }); + + test("accepts a single policy with multiple conditions", () => { + expect(() => + ResolverPermissionSchema.parse([ + { + conditions: [ + [{ user: "_loggedIn" }, "=", true], + [{ user: "role" }, "=", "ADMIN"], + ], + permit: true, + }, + ]), + ).not.toThrow(); + }); + + test("accepts multiple policies", () => { + expect(() => + ResolverPermissionSchema.parse([ + { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true }, + { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, + ]), + ).not.toThrow(); + }); + + test('accepts "allowAnonymous"', () => { + expect(() => ResolverPermissionSchema.parse("allowAnonymous")).not.toThrow(); + }); + + test("rejects an empty policy array", () => { + expect(() => ResolverPermissionSchema.parse([])).toThrow( + "permission must have at least one policy", + ); + }); + + test("rejects a policy with an empty conditions array", () => { + expect(() => ResolverPermissionSchema.parse([{ conditions: [], permit: true }])).toThrow( + "must have at least one condition", + ); + }); + + test("rejects a policy missing permit", () => { + expect(() => + ResolverPermissionSchema.parse([{ conditions: [[{ user: "_loggedIn" }, "=", true]] }]), + ).toThrow("permit"); + }); + + test("rejects a condition with no `user` operand on either side", () => { + expect(() => + ResolverPermissionSchema.parse([{ conditions: [["a", "=", "b"]], permit: true }]), + ).toThrow("must reference a `user` operand"); + }); + + test("rejects a condition comparing two `user` operands to each other", () => { + expect(() => + ResolverPermissionSchema.parse([ + { conditions: [[{ user: "role" }, "=", { user: "rol" }]], permit: true }, + ]), + ).toThrow("must reference a `user` operand"); + }); + + test("rejects `_loggedIn` compared to a string", () => { + expect(() => + ResolverPermissionSchema.parse([ + { conditions: [[{ user: "_loggedIn" }, "=", "true"]], permit: true }, + ]), + ).toThrow("`_loggedIn` must compare to a boolean"); + }); + + test("rejects `id` compared to a boolean", () => { + expect(() => + ResolverPermissionSchema.parse([{ conditions: [[{ user: "id" }, "=", true]], permit: true }]), + ).toThrow("`id` must compare to a string"); + }); + + test("accepts an arbitrary attribute compared to either a string or a boolean", () => { + expect(() => + ResolverPermissionSchema.parse([ + { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, + ]), + ).not.toThrow(); + expect(() => + ResolverPermissionSchema.parse([ + { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true }, + ]), + ).not.toThrow(); + }); +}); diff --git a/packages/sdk/src/parser/service/resolver/schema.ts b/packages/sdk/src/parser/service/resolver/schema.ts index e909bbff52..ffa5085383 100644 --- a/packages/sdk/src/parser/service/resolver/schema.ts +++ b/packages/sdk/src/parser/service/resolver/schema.ts @@ -7,6 +7,96 @@ export const QueryTypeSchema = z .union([z.literal("query"), z.literal("mutation")]) .describe("GraphQL operation type"); +const ResolverPermissionOperandSchema = z.union([ + z.object({ user: z.string() }).strict(), + z.string(), + z.boolean(), +]); + +const ResolverPermissionOperatorSchema = z.union([z.literal("="), z.literal("!=")]); + +const isUserOperand = (operand: z.infer) => + typeof operand === "object"; + +// Fixed `user` keys have a known value type; arbitrary user attributes don't +// (their declared type lives in the configure-layer generic, not here), so +// only these two are checked against the operand they're compared to. +const KNOWN_USER_OPERAND_TYPES: Record = { + _loggedIn: "boolean", + id: "string", +}; + +const operandTypeMismatch = ( + userOperand: z.infer, + otherOperand: z.infer, +) => { + if (typeof userOperand !== "object") { + return undefined; + } + const expected = KNOWN_USER_OPERAND_TYPES[userOperand.user]; + if ( + expected === undefined || + typeof otherOperand === "object" || + typeof otherOperand === expected + ) { + return undefined; + } + return { key: userOperand.user, expected }; +}; + +const ResolverPermissionConditionSchema = z + .tuple([ + ResolverPermissionOperandSchema, + ResolverPermissionOperatorSchema, + ResolverPermissionOperandSchema, + ]) + .refine( + ([left, , right]) => isUserOperand(left) !== isUserOperand(right), + "Resolver permission condition must reference a `user` operand on exactly one side " + + "(comparing two `user` operands to each other can match on `undefined === undefined`)", + ) + .superRefine(([left, , right], ctx) => { + for (const mismatch of [operandTypeMismatch(left, right), operandTypeMismatch(right, left)]) { + if (mismatch) { + ctx.addIssue({ + code: "custom", + message: `\`${mismatch.key}\` must compare to a ${mismatch.expected}`, + }); + } + } + }) + .readonly(); + +const ResolverPermissionPolicySchema = z.object({ + conditions: z.union([ + ResolverPermissionConditionSchema, + z + .array(ResolverPermissionConditionSchema) + .min(1, "Resolver permission policy must have at least one condition") + .readonly(), + ]), + permit: z.boolean(), + description: z + .string() + .optional() + .describe("Reason recorded for this policy, used in the access-denied error message"), +}); + +export const ResolverPermissionSchema = z + .union([ + z + .array(ResolverPermissionPolicySchema) + .min(1, "Resolver permission must have at least one policy") + .readonly(), + z.literal("allowAnonymous"), + ]) + .describe( + "Access requirement for this resolver, evaluated against the original caller " + + '(unaffected by `authInvoker`) before `body` runs. "allowAnonymous" documents that ' + + "anonymous callers are allowed. Omitted (default): unchanged, anonymous callers can " + + "reach the resolver", + ); + export const ResolverSchema = z.object({ operation: QueryTypeSchema.describe("GraphQL operation type (query or mutation)"), name: z.string().describe("Resolver name"), @@ -16,4 +106,5 @@ export const ResolverSchema = z.object({ output: TailorFieldSchema.describe("Output field definition"), publishEvents: z.boolean().optional().describe("Enable publishing events from this resolver"), authInvoker: AuthInvokerSchema.optional().describe("Machine user to execute this resolver as"), + permission: ResolverPermissionSchema.optional(), }); diff --git a/packages/sdk/src/types/auth.generated.ts b/packages/sdk/src/types/auth.generated.ts index 1e48f45465..77461fa106 100644 --- a/packages/sdk/src/types/auth.generated.ts +++ b/packages/sdk/src/types/auth.generated.ts @@ -19,7 +19,9 @@ export type OIDC = { clientID: string; /** OAuth2 client secret */ clientSecret: { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; }; /** OIDC provider URL */ @@ -155,7 +157,9 @@ export type SCIMAuthorization = { /** Bearer token secret (required for bearer type) */ bearerSecret?: | { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; } | undefined; @@ -225,17 +229,25 @@ export type SCIMResource = { tailorDBType: string; /** Core SCIM schema definition */ coreSchema: { - /** SCIM resource name */ + /** SCIM schema name */ name: string; + /** Schema attributes */ attributes: { + /** Attribute data type */ type: "string" | "number" | "boolean" | "datetime" | "complex"; - /** SCIM resource name */ + /** Attribute name */ name: string; + /** Attribute description */ description?: string | undefined; + /** Attribute mutability */ mutability?: "readOnly" | "readWrite" | "writeOnly" | undefined; + /** Whether the attribute is required */ required?: boolean | undefined; + /** Whether the attribute can have multiple values */ multiValued?: boolean | undefined; + /** Uniqueness constraint */ uniqueness?: "none" | "server" | "global" | undefined; + /** List of canonical values */ canonicalValues?: string[] | null | undefined; subAttributes?: any[] | null | undefined; }[]; @@ -267,9 +279,12 @@ export type TenantProviderInput = TenantProvider; export type AuthConfigInput = | { + /** Auth service name */ name: string; + /** Auth hooks */ hooks?: | { + /** Before login auth hook */ beforeLogin?: | { handler: Function; @@ -278,6 +293,7 @@ export type AuthConfigInput = | undefined; } | undefined; + /** Machine user definitions */ machineUsers?: | { [x: string]: { @@ -290,6 +306,7 @@ export type AuthConfigInput = }; } | undefined; + /** OAuth2 client definitions */ oauth2Clients?: | { [x: string]: { @@ -308,86 +325,138 @@ export type AuthConfigInput = }; } | undefined; + /** Identity provider configuration */ idProvider?: | { + /** Identity provider name */ name: string; kind: "SAML"; + /** Enable signing of SAML requests */ enableSignRequest?: boolean | undefined; + /** URL to fetch SAML metadata (mutually exclusive with rawMetadata) */ metadataURL?: string | undefined; + /** Raw SAML metadata XML (mutually exclusive with metadataURL) */ rawMetadata?: string | undefined; + /** URL to redirect to when SAML ACS receives a response with an empty RelayState. */ defaultRedirectURL?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "OIDC"; + /** OAuth2 client ID */ clientID: string; + /** OAuth2 client secret */ clientSecret: { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; }; + /** OIDC provider URL */ providerURL: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "IDToken"; + /** OIDC provider URL */ providerURL: string; + /** OAuth2 client ID */ clientID: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "BuiltInIdP"; + /** IdP namespace */ namespace: string; + /** OAuth2 client name in the IdP */ clientName: string; } | undefined; + /** SCIM provisioning configuration */ scim?: | { + /** Machine user name for SCIM operations */ machineUserName: string; + /** SCIM authorization configuration */ authorization: { + /** SCIM authorization type */ type: "oauth2" | "bearer"; + /** Bearer token secret (required for bearer type) */ bearerSecret?: | { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; } | undefined; }; + /** SCIM resource definitions */ resources: { + /** SCIM resource name */ name: string; + /** TailorDB namespace for the resource */ tailorDBNamespace: string; + /** TailorDB type name for the resource */ tailorDBType: string; + /** Core SCIM schema definition */ coreSchema: { + /** SCIM schema name */ name: string; + /** Schema attributes */ attributes: { + /** Attribute data type */ type: "string" | "number" | "boolean" | "datetime" | "complex"; + /** Attribute name */ name: string; + /** Attribute description */ description?: string | undefined; + /** Attribute mutability */ mutability?: "readOnly" | "readWrite" | "writeOnly" | undefined; + /** Whether the attribute is required */ required?: boolean | undefined; + /** Whether the attribute can have multiple values */ multiValued?: boolean | undefined; + /** Uniqueness constraint */ uniqueness?: "none" | "server" | "global" | undefined; + /** List of canonical values */ canonicalValues?: string[] | null | undefined; subAttributes?: any[] | null | undefined; }[]; }; + /** Attribute mapping configuration */ attributeMapping: { + /** TailorDB field name to map to */ tailorDBField: string; + /** SCIM attribute path */ scimPath: string; }[]; }[]; } | undefined; + /** Multi-tenant provider configuration */ tenantProvider?: | { + /** TailorDB namespace for the tenant type */ namespace: string; + /** TailorDB type name for tenants */ type: string; + /** Field used as the tenant signature */ signatureField: string; } | undefined; + /** Auth connection definitions for external OAuth2 providers */ connections?: | { [x: string]: { @@ -401,7 +470,9 @@ export type AuthConfigInput = }; } | undefined; + /** Enable publishing session events */ publishSessionEvents?: boolean | undefined; + /** User profile configuration */ userProfile?: | { type: { @@ -418,6 +489,7 @@ export type AuthConfigInput = _output: any; }; usernameField: string; + /** TailorDB namespace where the user type is defined */ namespace?: string | undefined; attributes?: | { @@ -427,10 +499,13 @@ export type AuthConfigInput = attributeList?: string[] | undefined; } | undefined; + /** Machine user attribute fields */ machineUserAttributes?: undefined; } | { + /** Auth service name */ name: string; + /** Machine user attribute fields */ machineUserAttributes: { [x: string]: { type: @@ -466,8 +541,10 @@ export type AuthConfigInput = fields: any; }; }; + /** Auth hooks */ hooks?: | { + /** Before login auth hook */ beforeLogin?: | { handler: Function; @@ -476,6 +553,7 @@ export type AuthConfigInput = | undefined; } | undefined; + /** Machine user definitions */ machineUsers?: | { [x: string]: { @@ -488,6 +566,7 @@ export type AuthConfigInput = }; } | undefined; + /** OAuth2 client definitions */ oauth2Clients?: | { [x: string]: { @@ -506,86 +585,138 @@ export type AuthConfigInput = }; } | undefined; + /** Identity provider configuration */ idProvider?: | { + /** Identity provider name */ name: string; kind: "SAML"; + /** Enable signing of SAML requests */ enableSignRequest?: boolean | undefined; + /** URL to fetch SAML metadata (mutually exclusive with rawMetadata) */ metadataURL?: string | undefined; + /** Raw SAML metadata XML (mutually exclusive with metadataURL) */ rawMetadata?: string | undefined; + /** URL to redirect to when SAML ACS receives a response with an empty RelayState. */ defaultRedirectURL?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "OIDC"; + /** OAuth2 client ID */ clientID: string; + /** OAuth2 client secret */ clientSecret: { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; }; + /** OIDC provider URL */ providerURL: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "IDToken"; + /** OIDC provider URL */ providerURL: string; + /** OAuth2 client ID */ clientID: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "BuiltInIdP"; + /** IdP namespace */ namespace: string; + /** OAuth2 client name in the IdP */ clientName: string; } | undefined; + /** SCIM provisioning configuration */ scim?: | { + /** Machine user name for SCIM operations */ machineUserName: string; + /** SCIM authorization configuration */ authorization: { + /** SCIM authorization type */ type: "oauth2" | "bearer"; + /** Bearer token secret (required for bearer type) */ bearerSecret?: | { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; } | undefined; }; + /** SCIM resource definitions */ resources: { + /** SCIM resource name */ name: string; + /** TailorDB namespace for the resource */ tailorDBNamespace: string; + /** TailorDB type name for the resource */ tailorDBType: string; + /** Core SCIM schema definition */ coreSchema: { + /** SCIM schema name */ name: string; + /** Schema attributes */ attributes: { + /** Attribute data type */ type: "string" | "number" | "boolean" | "datetime" | "complex"; + /** Attribute name */ name: string; + /** Attribute description */ description?: string | undefined; + /** Attribute mutability */ mutability?: "readOnly" | "readWrite" | "writeOnly" | undefined; + /** Whether the attribute is required */ required?: boolean | undefined; + /** Whether the attribute can have multiple values */ multiValued?: boolean | undefined; + /** Uniqueness constraint */ uniqueness?: "none" | "server" | "global" | undefined; + /** List of canonical values */ canonicalValues?: string[] | null | undefined; subAttributes?: any[] | null | undefined; }[]; }; + /** Attribute mapping configuration */ attributeMapping: { + /** TailorDB field name to map to */ tailorDBField: string; + /** SCIM attribute path */ scimPath: string; }[]; }[]; } | undefined; + /** Multi-tenant provider configuration */ tenantProvider?: | { + /** TailorDB namespace for the tenant type */ namespace: string; + /** TailorDB type name for tenants */ type: string; + /** Field used as the tenant signature */ signatureField: string; } | undefined; + /** Auth connection definitions for external OAuth2 providers */ connections?: | { [x: string]: { @@ -599,15 +730,20 @@ export type AuthConfigInput = }; } | undefined; + /** Enable publishing session events */ publishSessionEvents?: boolean | undefined; + /** User profile configuration */ userProfile?: undefined; }; export type AuthConfig = | { + /** Auth service name */ name: string; + /** Auth hooks */ hooks?: | { + /** Before login auth hook */ beforeLogin?: | { handler: Function; @@ -616,6 +752,7 @@ export type AuthConfig = | undefined; } | undefined; + /** Machine user definitions */ machineUsers?: | { [x: string]: { @@ -628,6 +765,7 @@ export type AuthConfig = }; } | undefined; + /** OAuth2 client definitions */ oauth2Clients?: | { [x: string]: { @@ -656,86 +794,138 @@ export type AuthConfig = }; } | undefined; + /** Identity provider configuration */ idProvider?: | { + /** Identity provider name */ name: string; kind: "SAML"; + /** Enable signing of SAML requests */ enableSignRequest: boolean; + /** URL to fetch SAML metadata (mutually exclusive with rawMetadata) */ metadataURL?: string | undefined; + /** Raw SAML metadata XML (mutually exclusive with metadataURL) */ rawMetadata?: string | undefined; + /** URL to redirect to when SAML ACS receives a response with an empty RelayState. */ defaultRedirectURL?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "OIDC"; + /** OAuth2 client ID */ clientID: string; + /** OAuth2 client secret */ clientSecret: { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; }; + /** OIDC provider URL */ providerURL: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "IDToken"; + /** OIDC provider URL */ providerURL: string; + /** OAuth2 client ID */ clientID: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "BuiltInIdP"; + /** IdP namespace */ namespace: string; + /** OAuth2 client name in the IdP */ clientName: string; } | undefined; + /** SCIM provisioning configuration */ scim?: | { + /** Machine user name for SCIM operations */ machineUserName: string; + /** SCIM authorization configuration */ authorization: { + /** SCIM authorization type */ type: "oauth2" | "bearer"; + /** Bearer token secret (required for bearer type) */ bearerSecret?: | { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; } | undefined; }; + /** SCIM resource definitions */ resources: { + /** SCIM resource name */ name: string; + /** TailorDB namespace for the resource */ tailorDBNamespace: string; + /** TailorDB type name for the resource */ tailorDBType: string; + /** Core SCIM schema definition */ coreSchema: { + /** SCIM schema name */ name: string; + /** Schema attributes */ attributes: { + /** Attribute data type */ type: "string" | "number" | "boolean" | "datetime" | "complex"; + /** Attribute name */ name: string; + /** Attribute description */ description?: string | undefined; + /** Attribute mutability */ mutability?: "readOnly" | "readWrite" | "writeOnly" | undefined; + /** Whether the attribute is required */ required?: boolean | undefined; + /** Whether the attribute can have multiple values */ multiValued?: boolean | undefined; + /** Uniqueness constraint */ uniqueness?: "none" | "server" | "global" | undefined; + /** List of canonical values */ canonicalValues?: string[] | null | undefined; subAttributes?: any[] | null | undefined; }[]; }; + /** Attribute mapping configuration */ attributeMapping: { + /** TailorDB field name to map to */ tailorDBField: string; + /** SCIM attribute path */ scimPath: string; }[]; }[]; } | undefined; + /** Multi-tenant provider configuration */ tenantProvider?: | { + /** TailorDB namespace for the tenant type */ namespace: string; + /** TailorDB type name for tenants */ type: string; + /** Field used as the tenant signature */ signatureField: string; } | undefined; + /** Auth connection definitions for external OAuth2 providers */ connections?: | { [x: string]: { @@ -749,7 +939,9 @@ export type AuthConfig = }; } | undefined; + /** Enable publishing session events */ publishSessionEvents?: boolean | undefined; + /** User profile configuration */ userProfile?: | { type: { @@ -766,6 +958,7 @@ export type AuthConfig = _output: any; }; usernameField: string; + /** TailorDB namespace where the user type is defined */ namespace?: string | undefined; attributes?: | { @@ -775,10 +968,13 @@ export type AuthConfig = attributeList?: string[] | undefined; } | undefined; + /** Machine user attribute fields */ machineUserAttributes?: undefined; } | { + /** Auth service name */ name: string; + /** Machine user attribute fields */ machineUserAttributes: { [x: string]: { type: @@ -814,8 +1010,10 @@ export type AuthConfig = fields: any; }; }; + /** Auth hooks */ hooks?: | { + /** Before login auth hook */ beforeLogin?: | { handler: Function; @@ -824,6 +1022,7 @@ export type AuthConfig = | undefined; } | undefined; + /** Machine user definitions */ machineUsers?: | { [x: string]: { @@ -836,6 +1035,7 @@ export type AuthConfig = }; } | undefined; + /** OAuth2 client definitions */ oauth2Clients?: | { [x: string]: { @@ -864,86 +1064,138 @@ export type AuthConfig = }; } | undefined; + /** Identity provider configuration */ idProvider?: | { + /** Identity provider name */ name: string; kind: "SAML"; + /** Enable signing of SAML requests */ enableSignRequest: boolean; + /** URL to fetch SAML metadata (mutually exclusive with rawMetadata) */ metadataURL?: string | undefined; + /** Raw SAML metadata XML (mutually exclusive with metadataURL) */ rawMetadata?: string | undefined; + /** URL to redirect to when SAML ACS receives a response with an empty RelayState. */ defaultRedirectURL?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "OIDC"; + /** OAuth2 client ID */ clientID: string; + /** OAuth2 client secret */ clientSecret: { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; }; + /** OIDC provider URL */ providerURL: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "IDToken"; + /** OIDC provider URL */ providerURL: string; + /** OAuth2 client ID */ clientID: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "BuiltInIdP"; + /** IdP namespace */ namespace: string; + /** OAuth2 client name in the IdP */ clientName: string; } | undefined; + /** SCIM provisioning configuration */ scim?: | { + /** Machine user name for SCIM operations */ machineUserName: string; + /** SCIM authorization configuration */ authorization: { + /** SCIM authorization type */ type: "oauth2" | "bearer"; + /** Bearer token secret (required for bearer type) */ bearerSecret?: | { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; } | undefined; }; + /** SCIM resource definitions */ resources: { + /** SCIM resource name */ name: string; + /** TailorDB namespace for the resource */ tailorDBNamespace: string; + /** TailorDB type name for the resource */ tailorDBType: string; + /** Core SCIM schema definition */ coreSchema: { + /** SCIM schema name */ name: string; + /** Schema attributes */ attributes: { + /** Attribute data type */ type: "string" | "number" | "boolean" | "datetime" | "complex"; + /** Attribute name */ name: string; + /** Attribute description */ description?: string | undefined; + /** Attribute mutability */ mutability?: "readOnly" | "readWrite" | "writeOnly" | undefined; + /** Whether the attribute is required */ required?: boolean | undefined; + /** Whether the attribute can have multiple values */ multiValued?: boolean | undefined; + /** Uniqueness constraint */ uniqueness?: "none" | "server" | "global" | undefined; + /** List of canonical values */ canonicalValues?: string[] | null | undefined; subAttributes?: any[] | null | undefined; }[]; }; + /** Attribute mapping configuration */ attributeMapping: { + /** TailorDB field name to map to */ tailorDBField: string; + /** SCIM attribute path */ scimPath: string; }[]; }[]; } | undefined; + /** Multi-tenant provider configuration */ tenantProvider?: | { + /** TailorDB namespace for the tenant type */ namespace: string; + /** TailorDB type name for tenants */ type: string; + /** Field used as the tenant signature */ signatureField: string; } | undefined; + /** Auth connection definitions for external OAuth2 providers */ connections?: | { [x: string]: { @@ -957,6 +1209,8 @@ export type AuthConfig = }; } | undefined; + /** Enable publishing session events */ publishSessionEvents?: boolean | undefined; + /** User profile configuration */ userProfile?: undefined; }; diff --git a/packages/sdk/src/types/executor.generated.ts b/packages/sdk/src/types/executor.generated.ts index 5ee8615fd1..82e1cda960 100644 --- a/packages/sdk/src/types/executor.generated.ts +++ b/packages/sdk/src/types/executor.generated.ts @@ -102,7 +102,9 @@ export type FunctionOperation = { authInvoker?: | string | { + /** Auth namespace */ namespace: string; + /** Machine user name for authentication */ machineUserName: string; } | undefined; @@ -120,7 +122,9 @@ export type GqlOperationInput = { authInvoker?: | string | { + /** Auth namespace */ namespace: string; + /** Machine user name for authentication */ machineUserName: string; } | undefined; @@ -137,7 +141,9 @@ export type GqlOperation = { authInvoker?: | string | { + /** Auth namespace */ namespace: string; + /** Machine user name for authentication */ machineUserName: string; } | undefined; @@ -214,21 +220,28 @@ export type Executor = { [x: string]: unknown; } | undefined; + /** Auth invoker for the function execution */ authInvoker?: | string | { + /** Auth namespace */ namespace: string; + /** Machine user name for authentication */ machineUserName: string; } | undefined; } | { kind: "function" | "jobFunction"; + /** Function implementation */ body: Function; + /** Auth invoker for the function execution */ authInvoker?: | string | { + /** Auth namespace */ namespace: string; + /** Machine user name for authentication */ machineUserName: string; } | undefined; @@ -236,20 +249,28 @@ export type Executor = { | { kind: "graphql"; query: string; + /** Target application name for the GraphQL query */ appName?: string | undefined; + /** Function to compute GraphQL variables */ variables?: Function | undefined; + /** Auth invoker for the function execution */ authInvoker?: | string | { + /** Auth namespace */ namespace: string; + /** Machine user name for authentication */ machineUserName: string; } | undefined; } | { kind: "webhook"; + /** Function returning the webhook URL */ url: Function; + /** Function to compute the request body */ requestBody?: Function | undefined; + /** HTTP headers for the webhook request */ headers?: | { [x: string]: diff --git a/packages/sdk/src/types/field.generated.ts b/packages/sdk/src/types/field.generated.ts index 3653e97349..c9dba79f44 100644 --- a/packages/sdk/src/types/field.generated.ts +++ b/packages/sdk/src/types/field.generated.ts @@ -16,21 +16,31 @@ export type TailorFieldInput = { | "nested"; /** Field metadata configuration */ metadata: { + /** Whether the field is required */ required?: boolean | undefined; + /** Whether the field is an array */ array?: boolean | undefined; + /** Field description */ description?: string | undefined; + /** Allowed values for enum fields */ allowedValues?: | { + /** The allowed value */ value: string; + /** Description of the allowed value */ description?: string | undefined; }[] | undefined; + /** Lifecycle hooks */ hooks?: | { + /** Hook function called on creation */ create?: Function | undefined; + /** Hook function called on update */ update?: Function | undefined; } | undefined; + /** Type name for nested or enum fields */ typeName?: string | undefined; }; fields: { @@ -54,21 +64,31 @@ export type TailorField = { | "nested"; /** Field metadata configuration */ metadata: { + /** Whether the field is required */ required?: boolean | undefined; + /** Whether the field is an array */ array?: boolean | undefined; + /** Field description */ description?: string | undefined; + /** Allowed values for enum fields */ allowedValues?: | { + /** The allowed value */ value: string; + /** Description of the allowed value */ description?: string | undefined; }[] | undefined; + /** Lifecycle hooks */ hooks?: | { + /** Hook function called on creation */ create?: Function | undefined; + /** Hook function called on update */ update?: Function | undefined; } | undefined; + /** Type name for nested or enum fields */ typeName?: string | undefined; }; fields: { diff --git a/packages/sdk/src/types/http-adapter.generated.ts b/packages/sdk/src/types/http-adapter.generated.ts index c02c13422b..1cee2d140a 100644 --- a/packages/sdk/src/types/http-adapter.generated.ts +++ b/packages/sdk/src/types/http-adapter.generated.ts @@ -7,10 +7,15 @@ export type HttpAdapterConfigInput = { pathPattern: string; /** Per-method functions that transform HTTP requests to GraphQL requests */ input: { + /** Handler for GET requests */ get?: Function | undefined; + /** Handler for POST requests */ post?: Function | undefined; + /** Handler for PUT requests */ put?: Function | undefined; + /** Handler for PATCH requests */ patch?: Function | undefined; + /** Handler for DELETE requests */ delete?: Function | undefined; }; /** Whether the adapter is active */ @@ -32,10 +37,15 @@ export type HttpAdapterConfig = { priority: number; /** Per-method functions that transform HTTP requests to GraphQL requests */ input: { + /** Handler for GET requests */ get?: Function | undefined; + /** Handler for POST requests */ post?: Function | undefined; + /** Handler for PUT requests */ put?: Function | undefined; + /** Handler for PATCH requests */ patch?: Function | undefined; + /** Handler for DELETE requests */ delete?: Function | undefined; }; /** Function that transforms GraphQL response to HTTP response */ diff --git a/packages/sdk/src/types/idp.generated.ts b/packages/sdk/src/types/idp.generated.ts index 32225103de..4fd34bf071 100644 --- a/packages/sdk/src/types/idp.generated.ts +++ b/packages/sdk/src/types/idp.generated.ts @@ -17,12 +17,19 @@ export type IdPGqlOperationsInput = }; export type IdPGqlOperations = { + /** Enable _createUser mutation (default: true) */ create?: boolean | undefined; + /** Enable _updateUser mutation (default: true) */ update?: boolean | undefined; + /** Enable _deleteUser mutation (default: true) */ delete?: boolean | undefined; + /** Enable _users and _user queries (default: true) */ read?: boolean | undefined; + /** Enable _sendPasswordResetEmail mutation (default: true) */ sendPasswordResetEmail?: boolean | undefined; + /** Enable _requestMfaSettingsUrl query (default: true) */ requestMfaSettingsUrl?: boolean | undefined; + /** Enable _unenrollMfa mutation (default: true) */ unenrollMfa?: boolean | undefined; }; @@ -1344,21 +1351,37 @@ export type IdPInput = { /** User authentication policy configuration */ userAuthPolicy?: | { + /** Use non-email identifier for usernames */ useNonEmailIdentifier?: boolean | undefined; + /** Allow users to reset their own passwords */ allowSelfPasswordReset?: boolean | undefined; + /** Require uppercase letters in passwords */ passwordRequireUppercase?: boolean | undefined; + /** Require lowercase letters in passwords */ passwordRequireLowercase?: boolean | undefined; + /** Require non-alphanumeric characters in passwords */ passwordRequireNonAlphanumeric?: boolean | undefined; + /** Require numeric characters in passwords */ passwordRequireNumeric?: boolean | undefined; + /** Minimum password length (6-30) */ passwordMinLength?: number | undefined; + /** Maximum password length (6-4096) */ passwordMaxLength?: number | undefined; + /** Restrict registration to these email domains */ allowedEmailDomains?: string[] | undefined; + /** Enable Google OAuth login */ allowGoogleOauth?: boolean | undefined; + /** Enable Microsoft OAuth login */ allowMicrosoftOauth?: boolean | undefined; + /** Disable password-based authentication */ disablePasswordAuth?: boolean | undefined; + /** Make TOTP MFA available for users in this namespace */ enableMfa?: boolean | undefined; + /** Require TOTP MFA enrollment and challenge for password-authenticated users (requires enableMfa) */ requireMfa?: boolean | undefined; + /** Application origins (scheme + host + optional port) allowed as MFA self-service return targets */ allowedReturnOrigins?: string[] | undefined; + /** Label shown next to the user account in authenticator apps */ mfaIssuer?: string | undefined; } | undefined; @@ -1368,12 +1391,19 @@ export type IdPInput = { gqlOperations?: | "query" | { + /** Enable _createUser mutation (default: true) */ create?: boolean | undefined; + /** Enable _updateUser mutation (default: true) */ update?: boolean | undefined; + /** Enable _deleteUser mutation (default: true) */ delete?: boolean | undefined; + /** Enable _users and _user queries (default: true) */ read?: boolean | undefined; + /** Enable _sendPasswordResetEmail mutation (default: true) */ sendPasswordResetEmail?: boolean | undefined; + /** Enable _requestMfaSettingsUrl query (default: true) */ requestMfaSettingsUrl?: boolean | undefined; + /** Enable _unenrollMfa mutation (default: true) */ unenrollMfa?: boolean | undefined; } | undefined; @@ -1401,21 +1431,37 @@ export type IdP = { /** User authentication policy configuration */ userAuthPolicy?: | { + /** Use non-email identifier for usernames */ useNonEmailIdentifier?: boolean | undefined; + /** Allow users to reset their own passwords */ allowSelfPasswordReset?: boolean | undefined; + /** Require uppercase letters in passwords */ passwordRequireUppercase?: boolean | undefined; + /** Require lowercase letters in passwords */ passwordRequireLowercase?: boolean | undefined; + /** Require non-alphanumeric characters in passwords */ passwordRequireNonAlphanumeric?: boolean | undefined; + /** Require numeric characters in passwords */ passwordRequireNumeric?: boolean | undefined; + /** Minimum password length (6-30) */ passwordMinLength?: number | undefined; + /** Maximum password length (6-4096) */ passwordMaxLength?: number | undefined; + /** Restrict registration to these email domains */ allowedEmailDomains?: string[] | undefined; + /** Enable Google OAuth login */ allowGoogleOauth?: boolean | undefined; + /** Enable Microsoft OAuth login */ allowMicrosoftOauth?: boolean | undefined; + /** Disable password-based authentication */ disablePasswordAuth?: boolean | undefined; + /** Make TOTP MFA available for users in this namespace */ enableMfa?: boolean | undefined; + /** Require TOTP MFA enrollment and challenge for password-authenticated users (requires enableMfa) */ requireMfa?: boolean | undefined; + /** Application origins (scheme + host + optional port) allowed as MFA self-service return targets */ allowedReturnOrigins?: string[] | undefined; + /** Label shown next to the user account in authenticator apps */ mfaIssuer?: string | undefined; } | undefined; diff --git a/packages/sdk/src/types/resolver.generated.ts b/packages/sdk/src/types/resolver.generated.ts index 923105548f..fca9ed1d18 100644 --- a/packages/sdk/src/types/resolver.generated.ts +++ b/packages/sdk/src/types/resolver.generated.ts @@ -6,6 +6,28 @@ export type QueryType = "query" | "mutation"; export type QueryTypeInput = QueryType; +/** + * Access requirement for this resolver, evaluated against the original caller (unaffected by `authInvoker`) before `body` runs. "allowAnonymous" documents that anonymous callers are allowed. Omitted (default): unchanged, anonymous callers can reach the resolver + */ +export type ResolverPermission = + | "allowAnonymous" + | readonly { + conditions: + | readonly [ + string | boolean | { user: string }, + "=" | "!=", + string | boolean | { user: string }, + ] + | readonly (readonly [ + string | boolean | { user: string }, + "=" | "!=", + string | boolean | { user: string }, + ])[]; + permit: boolean; + description?: string | undefined; + }[]; +export type ResolverPermissionInput = ResolverPermission; + export type Resolver = { /** GraphQL operation type (query or mutation) */ operation: QueryType; @@ -15,6 +37,7 @@ export type Resolver = { body: Function; /** Output field definition */ output: { + /** Field data type */ type: | "string" | "boolean" @@ -27,24 +50,33 @@ export type Resolver = { | "datetime" | "time" | "nested"; + /** Field metadata configuration */ metadata: { + /** Whether the field is required */ required?: boolean | undefined; + /** Whether the field is an array */ array?: boolean | undefined; - /** Resolver description */ + /** Field description */ description?: string | undefined; + /** Allowed values for enum fields */ allowedValues?: | { + /** The allowed value */ value: string; - /** Resolver description */ + /** Description of the allowed value */ description?: string | undefined; }[] | undefined; + /** Lifecycle hooks */ hooks?: | { + /** Hook function called on creation */ create?: Function | undefined; + /** Hook function called on update */ update?: Function | undefined; } | undefined; + /** Type name for nested or enum fields */ typeName?: string | undefined; }; fields: { @@ -65,9 +97,54 @@ export type Resolver = { authInvoker?: | string | { + /** Auth namespace */ namespace: string; + /** Machine user name for authentication */ machineUserName: string; } | undefined; + permission?: + | "allowAnonymous" + | readonly { + conditions: + | readonly [ + ( + | string + | boolean + | { + user: string; + } + ), + "=" | "!=", + ( + | string + | boolean + | { + user: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | { + user: string; + } + ), + "=" | "!=", + ( + | string + | boolean + | { + user: string; + } + ), + ])[]; + permit: boolean; + /** Reason recorded for this policy, used in the access-denied error message */ + description?: string | undefined; + }[] + | undefined; }; export type ResolverInput = Resolver; diff --git a/packages/sdk/src/types/tailordb.generated.ts b/packages/sdk/src/types/tailordb.generated.ts index d1ff55fe44..2f0c4c8a45 100644 --- a/packages/sdk/src/types/tailordb.generated.ts +++ b/packages/sdk/src/types/tailordb.generated.ts @@ -14,9 +14,13 @@ export type GqlOperationsInput = }; export type GqlOperations = { + /** Enable create mutation (default: true) */ create?: boolean | undefined; + /** Enable update mutation (default: true) */ update?: boolean | undefined; + /** Enable delete mutation (default: true) */ delete?: boolean | undefined; + /** Enable read queries - get, list, aggregation (default: true) */ read?: boolean | undefined; }; @@ -33,7 +37,6 @@ export type DBFieldMetadata = { allowedValues?: | { value: string; - /** Field description */ description?: string | undefined; }[] | undefined; @@ -52,7 +55,9 @@ export type DBFieldMetadata = { /** Lifecycle hooks for the field */ hooks?: | { + /** Hook function called on record creation */ create?: Function | undefined; + /** Hook function called on record update */ update?: Function | undefined; } | undefined; @@ -61,8 +66,11 @@ export type DBFieldMetadata = { /** Serial (auto-increment) configuration */ serial?: | { + /** Starting value for the serial sequence */ start: number; + /** Maximum value for the serial sequence */ maxValue?: number | undefined; + /** Format string for serial value (string type only) */ format?: string | undefined; } | undefined; @@ -75,9 +83,11 @@ export type RawRelationConfig = { /** Relation cardinality type */ type: "1-1" | "n-1" | "keyOnly" | "oneToOne" | "manyToOne" | "N-1"; toward: { - /** Relation cardinality type */ + /** Target type name, or 'self' for self-relations */ type: string; + /** Custom forward relation name */ as?: string | undefined; + /** Target field to join on (default: 'id') */ key?: string | undefined; }; /** Backward relation name on the target type */ @@ -96,9 +106,13 @@ export type TailorDBTypeParsedSettingsInput = { gqlOperations?: | "query" | { + /** Enable create mutation (default: true) */ create?: boolean | undefined; + /** Enable update mutation (default: true) */ update?: boolean | undefined; + /** Enable delete mutation (default: true) */ delete?: boolean | undefined; + /** Enable read queries - get, list, aggregation (default: true) */ read?: boolean | undefined; } | undefined; @@ -1098,7 +1112,9 @@ export type TailorDBServiceConfigInput = { /** Migration configuration */ migration?: | { + /** Directory containing migration files */ directory: string; + /** Machine user name for migration execution */ machineUser?: string | undefined; } | undefined; @@ -1106,9 +1122,13 @@ export type TailorDBServiceConfigInput = { gqlOperations?: | "query" | { + /** Enable create mutation (default: true) */ create?: boolean | undefined; + /** Enable update mutation (default: true) */ update?: boolean | undefined; + /** Enable delete mutation (default: true) */ delete?: boolean | undefined; + /** Enable read queries - get, list, aggregation (default: true) */ read?: boolean | undefined; } | undefined; @@ -1124,7 +1144,9 @@ export type TailorDBServiceConfig = { /** Migration configuration */ migration?: | { + /** Directory containing migration files */ directory: string; + /** Machine user name for migration execution */ machineUser?: string | undefined; } | undefined; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 956276573e..4276f28338 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -631,8 +631,8 @@ importers: specifier: 4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) zinfer: - specifier: 0.2.5 - version: 0.2.5(typescript@6.0.3)(zod@4.4.3) + specifier: 0.2.7 + version: 0.2.7(typescript@6.0.3)(zod@4.4.3) packages/sdk-codemod: dependencies: @@ -4178,8 +4178,8 @@ packages: yuku-parser@0.6.1: resolution: {integrity: sha512-dPE3/+H2VBw9LhjoIVeW/axKidYGd+XzNtrwGGseZ0325cQFl0Dpwyh0R74XWe/WqQn4M8CR5YApsv2KF2zN1A==} - zinfer@0.2.5: - resolution: {integrity: sha512-SQC0tsLjw4FJvCzL3V0GI1SGNZVR9p/8bFIG0NsUIwbpbOUysRXRBLeTvxruHgDTfNJOObMG2/nfXaMGZn8hHw==} + zinfer@0.2.7: + resolution: {integrity: sha512-D/GL7q03XFPNHeDnsRmfBladuatreu6C31XVKhMCoNsw0FTCZ7+jBnDxdi7//WO8bGP/UonuiISEoX8p/UdrPw==} hasBin: true peerDependencies: typescript: '>=5.0.0' @@ -7139,7 +7139,7 @@ snapshots: '@yuku-parser/binding-win32-arm64': 0.6.1 '@yuku-parser/binding-win32-x64': 0.6.1 - zinfer@0.2.5(typescript@6.0.3)(zod@4.4.3): + zinfer@0.2.7(typescript@6.0.3)(zod@4.4.3): dependencies: commander: 15.0.0 glob: 13.0.6