Skip to content

feat(resolver): add declarative permission field to createResolver#1718

Open
toiroakr wants to merge 12 commits into
mainfrom
feat/resolver-permission
Open

feat(resolver): add declarative permission field to createResolver#1718
toiroakr wants to merge 12 commits into
mainfrom
feat/resolver-permission

Conversation

@toiroakr

@toiroakr toiroakr commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add a permission field to createResolver for declaring a resolver's access requirement, evaluated against context.user (the original caller, unaffected by authInvoker) before body runs
  • permission is an array of { conditions, permit } policies, matching TailorDB's .permission() notation restricted to user operands (_loggedIn, id, or any user attribute) with equality (=/!=) comparisons, since a resolver has no associated record to compare against
  • Policies combine like an allow-list with an explicit-deny override: a permit: false policy always denies matching callers; with no permit: true policy, permission is a pure blocklist (everyone else allowed), and with at least one, it's an allow-list (denied by default). This lets a resolver express multiple eligibility paths, e.g. allowing machine-user callers unconditionally while gating regular users behind a role check
  • permission: "allowAnonymous" explicitly documents that anonymous callers are allowed; omitting permission keeps prior behavior unchanged (no breaking change)
  • Conditions are compiled to a JS guard at bundle time and injected into the resolver entry wrapper, which throws TailorErrorMessage when the caller doesn't match, attributing the message only to the policy/policies that actually caused the denial
  • permit is required (no implicit allow/deny default), since a silently inverted default would be actively dangerous for a login gate
  • Shares the user-operand type helpers (StringFieldKeys, UserStringOperand, etc.) with TailorDB's and IdP's permission systems via a new configure/types/permission-operand.types.ts module, instead of a third copy

Notes

Previously, a resolver with no in-body check was reachable by an anonymous caller by default, and TailorDB record-level permissions don't reliably protect against this once authInvoker swaps in a machine user's JWT. This gives resolvers a first-class, declarative, greppable way to require authentication.

toiroakr added 2 commits July 10, 2026 11:09
`createResolver` had no declarative auth mechanism, so a resolver with no
in-body check was reachable by an anonymous caller by default. Add an
optional `auth: "loggedIn" | "public"` field: "loggedIn" rejects anonymous
callers (based on `context.user`, unaffected by `authInvoker`) before
`body` runs; "public" documents that anonymous access is intentional.
Omitting `auth` keeps prior behavior unchanged.
Replace the "loggedIn" | "public" enum with a declarative
`{ conditions, permit }` policy, matching TailorDB's `.permission()`
notation restricted to `user` operands (a resolver has no associated
record) with equality (`=`/`!=`) comparisons only. Supports the
`_loggedIn`, `id`, and arbitrary user-attribute operands.

The bundler compiles the conditions into a JS boolean guard at bundle
time and injects it into the resolver entry wrapper, still evaluated
against `context.user` (unaffected by `authInvoker`) before `body` runs.
`permit` is required (no implicit allow/deny default) since a silently
inverted default is actively dangerous for a login gate. `auth: "public"`
remains as an explicit opt-out marker.
@changeset-bot

changeset-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6214453

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@tailor-platform/sdk Minor
@tailor-platform/create-sdk Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

pnpm add https://pkg.pr.new/@tailor-platform/create-sdk@6214453
pnpm add https://pkg.pr.new/@tailor-platform/eslint-plugin-sdk@6214453
pnpm add https://pkg.pr.new/@tailor-platform/sdk@6214453

commit: 6214453

@github-actions

This comment has been minimized.

Change `auth` to always be an array of `{ conditions, permit }`
policies (matching gqlPermission's convention of always wrapping
policies in an array), so a resolver can express multiple eligibility
paths — e.g. allow machine-user callers unconditionally via an
attribute check, while gating regular users behind a role condition.
A single-condition case just means a one-element array.

Policies combine like an allow-list with an explicit-deny override:
a `permit: false` policy always denies matching callers; with no
`permit: true` policy, `auth` is a pure blocklist (everyone else is
allowed), and with at least one, it becomes an allow-list (denied by
default, granted only by a matching `permit: true` policy). This also
fixes a bug in the initial single-policy version where a deny-only
policy (no allow policies at all) would deny everyone unconditionally
instead of acting as a blocklist.
@github-actions

This comment has been minimized.

This comment was marked as off-topic.

Address Copilot review feedback on PR #1718 (this repo):
- Require at least one condition in a policy's `conditions` array,
  and at least one policy in `auth`, via zod .min(1) with descriptive
  error messages (an empty array previously produced a silent no-op
  guard instead of a validation error)
- Add a defensive check in the bundle-time JS compiler that throws a
  clear error instead of emitting invalid code if either array is
  somehow empty at that point (schema validation should already
  prevent this, but the compiler should not assume it)
- Fix outdated JSDoc on `auth` that described only allow-list
  semantics, omitting the blocklist mode when no `permit: true`
  policy is present
- Add parser/service/resolver/schema.test.ts covering the new
  validation
@github-actions

This comment has been minimized.

This comment was marked as resolved.

Rename the field to match TailorDB's `.permission()`/`.gqlPermission()`
and IdP's `permission` field naming convention, rather than
introducing a new "auth" vocabulary. No behavior change — same
`{ conditions, permit }` policy shape, still evaluated against
`context.user` before `body` runs.
@toiroakr toiroakr changed the title feat(resolver): add declarative auth field to createResolver feat(resolver): add declarative permission field to createResolver Jul 10, 2026
@toiroakr
toiroakr requested a review from Copilot July 10, 2026 05:57

This comment was marked as off-topic.

@github-actions

This comment has been minimized.

@toiroakr
toiroakr marked this pull request as ready for review July 15, 2026 07:28
@toiroakr
toiroakr requested a review from a team as a code owner July 15, 2026 07:28
# Conflicts:
#	packages/sdk/src/cli/services/resolver/bundler.ts
#	packages/sdk/src/types/resolver.generated.ts
@github-actions

This comment has been minimized.

Address dqn review feedback on PR #1718 (this repo):

- The thrown TailorErrorMessage previously concatenated the
  description of every policy in `permission`, regardless of which
  one actually caused the denial. A caller denied purely for failing
  the allow-list (e.g. not logged in) would also see unrelated
  `permit: false` policy descriptions in the message. Now the guard
  compiles each policy to a `{ matched, description }` entry and only
  joins the description(s) of the policy/policies that actually
  matched at runtime.
- Deduplicate the operand-key-extraction type helpers
  (StringFieldKeys/BooleanFieldKeys/UserStringOperand/UserBooleanOperand
  and their array variants), which were copied verbatim across
  tailordb/idp/resolver permission.ts, into a new shared pure type
  module: configure/types/permission-operand.types.ts.
@github-actions

This comment has been minimized.

This comment was marked as off-topic.

Address remiposo review feedback on PR #1718 (this repo): the
`## Authentication` heading grouped the new `permission` field
(access-control conditions) and `authInvoker` (execution-identity
delegation) under a label that does not match either subsection well.
TailorDB's docs already use `### Permissions` for the equivalent
`.permission()`/`.gqlPermission()` feature, so rename to `## Permissions`
for consistency.
@github-actions

This comment has been minimized.

This comment was marked as off-topic.

"public" is a visibility-oriented adjective that does not fit the
"permission: X" phrasing and does not match this feature's own docs/
JSDoc, which consistently describe the concept as "anonymous callers
are allowed" rather than "public". Rename the sentinel to
"allowAnonymous", an action-phrase that both reads naturally as a
`permission` value and reuses the existing anonymous-caller vocabulary
established elsewhere in this feature. No behavior change.
@github-actions

This comment has been minimized.

This comment was marked as resolved.

…type

Reject permission conditions where neither side references `{ user: ... }`
(e.g. `["a", "=", "b"]`), matching the configure-layer type which already
requires this. Also widen `ResolverPermission` to include the
`"allowAnonymous"` sentinel so it matches the field's actual accepted values.
@github-actions

This comment has been minimized.

This comment was marked as off-topic.

toiroakr added 2 commits July 17, 2026 09:13
# Conflicts:
#	packages/sdk/src/cli/services/resolver/bundler.test.ts
#	packages/sdk/src/cli/services/resolver/bundler.ts
Merging origin/main brought in a refactor that bundles resolvers
through an in-memory virtual entry instead of writing a physical
`.tailor-sdk/resolvers/*.entry.js` file, so the two permission-guard
tests introduced in this branch no longer had a file to read. Read the
bundled code from bundleResolvers()'s returned map instead, matching
the pattern already used by the other bundler tests.

The permission-guard assertion also switched from matching the
unminified `context.user.type !== ""` literal to `user.type!==`,
since real (non-mocked) minification renames locals and normalizes
string-literal quoting.
@github-actions

Copy link
Copy Markdown

Code Metrics Report (packages/sdk)

main (d3b5f14) #1718 (90b9732) +/-
Coverage 74.4% 74.5% +0.0%
Code to Test Ratio 1:0.4 1:0.4 +0.0
Details
  |                    | main (d3b5f14) | #1718 (90b9732) |  +/-  |
  |--------------------|----------------|-----------------|-------|
+ | Coverage           |          74.4% |           74.5% | +0.0% |
  |   Files            |            454 |             454 |     0 |
  |   Lines            |          16897 |           16931 |   +34 |
+ |   Covered          |          12582 |           12616 |   +34 |
+ | Code to Test Ratio |          1:0.4 |           1:0.4 |  +0.0 |
  |   Code             |         113176 |          113627 |  +451 |
+ |   Test             |          52461 |           52708 |  +247 |

Code coverage of files in pull request scope (96.2% → 97.7%)

Files Coverage +/- Status
packages/sdk/src/cli/services/resolver/bundler.ts 94.4% +0.1% modified
packages/sdk/src/cli/shared/runtime-exprs.ts 100.0% 0.0% modified
packages/sdk/src/configure/services/idp/permission.ts 100.0% 0.0% modified
packages/sdk/src/configure/services/resolver/index.ts 0.0% 0.0% modified
packages/sdk/src/configure/services/resolver/resolver.ts 100.0% 0.0% modified
packages/sdk/src/configure/services/tailordb/permission.ts 100.0% 0.0% modified
packages/sdk/src/parser/service/resolver/schema.ts 100.0% 0.0% modified

SDK Configure Bundle Size

main (d3b5f14) #1718 (90b9732) +/-
configure-index-size 32.17KB 32.86KB 0.69KB
dependency-chunks-size 29.88KB 29.88KB 0KB
total-bundle-size 62.05KB 62.74KB 0.69KB

Runtime Performance

main (d3b5f14) #1718 (90b9732) +/-
Generate Median 2,854ms 2,860ms 6ms
Generate Max 2,908ms 2,970ms 62ms
Apply Build Median 2,896ms 2,888ms -8ms
Apply Build Max 2,906ms 2,909ms 3ms

Type Performance (instantiations)

main (d3b5f14) #1718 (90b9732) +/-
tailordb-basic 42,869 42,860 -9
tailordb-optional 4,451 4,451 0
tailordb-relation 6,220 6,220 0
tailordb-validate 753 753 0
tailordb-hooks 5,279 5,279 0
tailordb-object 12,547 12,547 0
tailordb-enum 1,486 1,486 0
resolver-basic 9,252 9,265 13
resolver-nested 26,119 26,132 13
resolver-array 18,059 18,072 13
executor-schedule 4,310 4,310 0
executor-webhook 949 949 0
executor-record 6,762 6,762 0
executor-resolver 4,090 4,111 21
executor-operation-function 937 937 0
executor-operation-gql 945 945 0
executor-operation-webhook 956 956 0
executor-operation-workflow 1,798 1,798 0

Reported by octocov

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Comment on lines +18 to +31
const isUserOperand = (operand: z.infer<typeof ResolverPermissionOperandSchema>) =>
typeof operand === "object";

const ResolverPermissionConditionSchema = z
.tuple([
ResolverPermissionOperandSchema,
ResolverPermissionOperatorSchema,
ResolverPermissionOperandSchema,
])
.refine(
([left, , right]) => isUserOperand(left) || isUserOperand(right),
"Resolver permission condition must reference a `user` operand on at least one side",
)
.readonly();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants