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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/resolver-permission-field.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 44 additions & 1 deletion packages/sdk/docs/services/resolver.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 attribute enabled in `auth.userProfile.attributes` (or `auth.machineUserAttributes` for machine users)

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:

Expand Down
56 changes: 56 additions & 0 deletions packages/sdk/src/cli/services/resolver/bundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down
7 changes: 6 additions & 1 deletion packages/sdk/src/cli/services/resolver/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { buildResolverPermissionGuardExpr, 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"];
}

/**
Expand Down Expand Up @@ -71,6 +73,7 @@ export async function bundleResolvers(
resolvers.push({
name: resolver.name,
sourceFile: file,
permission: resolver.permission,
});
}

Expand Down Expand Up @@ -127,13 +130,15 @@ async function bundleSingleResolver(
contextHash,
async build(cachePlugins) {
const absoluteSourcePath = path.resolve(resolver.sourceFile);
const permissionGuardExpr = buildResolverPermissionGuardExpr(resolver.permission);

const entryContent = ml /* js */ `
import _internalResolver from "${absoluteSourcePath}";
import { t } from "@tailor-platform/sdk";

const $tailor_resolver_body = async (context) => {
const invoker = ${INVOKER_EXPR};
${permissionGuardExpr ?? ""}
if (_internalResolver.input) {
const result = t.object(_internalResolver.input).parse({
value: context.input,
Expand Down
192 changes: 191 additions & 1 deletion packages/sdk/src/cli/shared/runtime-exprs.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -103,3 +107,189 @@ describe("buildResolverOperationHookExpr", () => {
expect(expr).toContain("env: {}");
});
});

describe("buildResolverPermissionGuardExpr", () => {
class TailorErrorMessage extends Error {}

function runGuard(
permission: Parameters<typeof buildResolverPermissionGuardExpr>[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("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/);
});
});
Loading
Loading