feat(resolver): add declarative permission field to createResolver#1718
Open
toiroakr wants to merge 12 commits into
Open
feat(resolver): add declarative permission field to createResolver#1718toiroakr wants to merge 12 commits into
toiroakr wants to merge 12 commits into
Conversation
`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 detectedLatest commit: 6214453 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
commit: |
This comment has been minimized.
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.
This comment has been minimized.
This comment has been minimized.
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
This comment has been minimized.
This comment has been minimized.
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.
This comment has been minimized.
This comment has been minimized.
toiroakr
marked this pull request as ready for review
July 15, 2026 07:28
# Conflicts: # packages/sdk/src/cli/services/resolver/bundler.ts # packages/sdk/src/types/resolver.generated.ts
This comment has been minimized.
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.
This comment has been minimized.
This comment has been minimized.
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.
This comment has been minimized.
This comment has been minimized.
"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.
This comment has been minimized.
This comment has been minimized.
…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.
This comment has been minimized.
This comment has been minimized.
# 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.
Code Metrics Report (packages/sdk)
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%)
SDK Configure Bundle Size
Runtime Performance
Type Performance (instantiations)
Reported by octocov |
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(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
permissionfield tocreateResolverfor declaring a resolver's access requirement, evaluated againstcontext.user(the original caller, unaffected byauthInvoker) beforebodyrunspermissionis an array of{ conditions, permit }policies, matching TailorDB's.permission()notation restricted touseroperands (_loggedIn,id, or any user attribute) with equality (=/!=) comparisons, since a resolver has no associated record to compare againstpermit: falsepolicy always denies matching callers; with nopermit: truepolicy,permissionis 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 checkpermission: "allowAnonymous"explicitly documents that anonymous callers are allowed; omittingpermissionkeeps prior behavior unchanged (no breaking change)TailorErrorMessagewhen the caller doesn't match, attributing the message only to the policy/policies that actually caused the denialpermitis required (no implicit allow/deny default), since a silently inverted default would be actively dangerous for a login gateuser-operand type helpers (StringFieldKeys,UserStringOperand, etc.) with TailorDB's and IdP's permission systems via a newconfigure/types/permission-operand.types.tsmodule, instead of a third copyNotes
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
authInvokerswaps in a machine user's JWT. This gives resolvers a first-class, declarative, greppable way to require authentication.