Describe the feature you'd like to request
In collaborative applications, authorization is defined on a parent record (e.g., a Document with owner, editors, viewers fields) and child records (e.g., DocumentBlock) should inherit those permissions. Today, the only way to achieve this is duplicating ownersDefinedIn arrays on every child model and keeping them in sync — either manually or via a Lambda-based cascade.
This is fragile, expensive, and error-prone:
- Data duplication: Every child record stores redundant copies of the permission arrays
- Sync complexity: A Lambda function must cascade permission changes to all child records whenever the parent's permissions change
- Race conditions: Between the parent update and the cascade completing, child records have stale permissions
- Subscription leaks: Real-time subscribers may receive events during the sync gap
- Cost: DynamoDB write capacity scales with
(number of child records) × (permission change frequency) instead of being constant
Current schema (with duplication):
Document: a.model({
name: a.string().required(),
blocks: a.hasMany("DocumentBlock", "documentId"),
editors: a.string().array(),
viewers: a.string().array(),
})
.authorization((allow) => [
allow.owner(),
allow.ownersDefinedIn("editors").to(["read", "update", "create", "delete"]),
allow.ownersDefinedIn("viewers").to(["read"]),
]),
DocumentBlock: a.model({
content: a.string(),
documentId: a.id().required(),
document: a.belongsTo("Document", "documentId"),
// ❌ Duplicated on every child record, must be kept in sync
editors: a.string().array(),
viewers: a.string().array(),
})
.authorization((allow) => [
allow.owner(),
allow.ownersDefinedIn("editors").to(["read", "update", "create", "delete"]),
allow.ownersDefinedIn("viewers").to(["read"]),
]),
This pattern is common in any multi-user application: project management tools (Project → Task), document editors (Document → Block), shared notebooks (Notebook → Cell), CRM systems (Account → Contact), etc.
Describe the solution you'd like
A new authorization strategy throughRelation that tells the auth transformer: "this model's permissions are derived from a parent model's auth rules via a belongsTo relationship."
Proposed schema:
Document: a.model({
name: a.string().required(),
blocks: a.hasMany("DocumentBlock", "documentId"),
editors: a.string().array(),
viewers: a.string().array(),
})
.authorization((allow) => [
allow.owner(),
allow.ownersDefinedIn("editors").to(["read", "update", "create", "delete"]),
allow.ownersDefinedIn("viewers").to(["read"]),
]),
DocumentBlock: a.model({
content: a.string(),
documentId: a.id().required(),
document: a.belongsTo("Document", "documentId"),
// ✅ No duplicated permission fields
})
.authorization((allow) => [
allow.throughRelation("document"), // inherits Document's auth rules
]),
At transform time, the auth transformer follows the belongsTo relationship from DocumentBlock.document → Document, reads Document's auth rules, and generates resolvers that resolve the calling user's allowed operations (not roles) from the parent record.
At runtime, the generated resolver:
- Determines the parent record (via FK or
ctx.source)
- Checks the user's identity against the parent's auth fields (
owner, editors, viewers)
- Resolves which CRUD operations the user is allowed based on the parent's
.to([...]) definitions
- Authorizes or rejects based on whether the current operation is in the allowed set
The child model has no opinion about roles. The developer defines roles and their operation mappings once on the parent model. Child models just say "check the parent."
Use cases and expected resolver behavior
1. Direct query — get a single child record
client.models.DocumentBlock.get({ id: "block-123" })
The generated resolver must:
- First fetch the DocumentBlock to obtain its
documentId
- Then GetItem on the Document table using
documentId
- Check
ctx.identity.sub against Document's owner / editors / viewers
- If the user has
read permission → return the block
- If not →
$util.unauthorized()
2. Direct query — list child records by parent
client.models.DocumentBlock.listBlocksByDocumentId({ documentId: "doc-abc" })
The generated resolver must:
- GetItem on Document
"doc-abc" in the auth pipeline slot
- Check permissions → if user has
read → proceed with DynamoDB Query on the GSI
- If not →
$util.unauthorized()
3. Create a child record
client.models.DocumentBlock.create({ documentId: "doc-abc", content: "Hello" })
The generated resolver must:
- GetItem on Document
"doc-abc" using the documentId from the mutation input
- Check permissions → if user has
create → proceed
- If not →
$util.unauthorized()
4. Update a child record
client.models.DocumentBlock.update({ id: "block-123", content: "Updated" })
The generated resolver must:
- First fetch the existing DocumentBlock to get
documentId
- Then GetItem on Document using that
documentId
- Check permissions → if user has
update → proceed
- If not →
$util.unauthorized()
5. Delete a child record
client.models.DocumentBlock.delete({ id: "block-123" })
Same as update — fetch existing record to get documentId, then check parent for delete permission.
6. Relational traversal — parent selectionSet includes children (list)
client.models.Document.list({
selectionSet: ["id", "name", "blocks.*"],
})
The Document.blocks field resolver fires. ctx.source already contains the full Document record including owner, editors, viewers. The generated resolver:
- Checks
ctx.identity.sub against ctx.source.owner / ctx.source.editors / ctx.source.viewers
- If user has
read → proceed with the relational query
- If not → return empty /
$util.unauthorized()
- No extra DynamoDB call needed — parent data is already in
ctx.source
7. Relational traversal — parent selectionSet includes children (get)
client.models.Document.get(
{ id: "doc-abc" },
{ selectionSet: ["id", "name", "blocks.*"] },
)
Same as use case 6 — ctx.source is available, no extra read needed.
Runtime operation resolution (not role-based)
The resolver does not expose or communicate roles. It resolves operations:
Parent model defines:
owner → [create, read, update, delete]
editors[] → [create, read, update, delete]
viewers[] → [read]
At runtime for a given user:
ctx.identity.sub == document.owner? → allowed: [create, read, update, delete]
ctx.identity.sub in document.editors? → allowed: [create, read, update, delete]
ctx.identity.sub in document.viewers? → allowed: [read]
none? → allowed: []
Current operation is "update"?
"update" in allowed → ✅ proceed
"update" not in allowed → ❌ unauthorized
If the developer later adds allow.ownersDefinedIn("commenters").to(["read", "create"]) on Document, every child model with throughRelation("document") automatically inherits this on the next deployment — zero changes to child models.
Describe alternatives you've considered
1. Duplicating ownersDefinedIn arrays on every child model (current approach)
Works but requires a Lambda function to cascade permission changes to all child records. Has race conditions during the sync window, scales writes linearly with child record count, and makes the schema definition repetitive and error-prone. See the "current schema" example above.
2. Lambda authorizer (allow.custom())
A Lambda function as the AppSync authorizer. The Lambda receives the authorizationToken and requestContext, parses the GraphQL query to find the documentId, fetches the parent, and checks permissions.
Problems:
- Adds 50-200ms latency on every GraphQL operation (Lambda cold start + execution)
- AppSync caches Lambda auth responses by token, not by operation — a cached "yes" for Document A incorrectly authorizes Document B unless TTL is 0
- Requires parsing raw GraphQL query strings to extract arguments — fragile
- Subscriptions only check auth on connection, not per-event — users keep receiving events after losing access
- No type safety between the authorizer and the schema
3. Custom queries/mutations with pipeline resolvers
Disable auto-generated CRUD via .disableOperations(), define custom queries/mutations with a.handler.custom() pipeline resolvers that do the parent lookup. This is architecturally sound but:
- Requires ~30-35 custom operations for a typical parent-child setup (7 operations × 5 child models)
- Loses auto-generated type-safe CRUD on the client
- Loses auto-generated subscriptions (must define custom ones per mutation)
- Every client call site must change from
client.models.X.create() to client.queries.createX()
4. @auth with groupsDefinedIn pointing to a Cognito group per document
Create a Cognito group per document and assign users to it. This doesn't scale — Cognito has a limit of 10,000 groups per user pool and managing group membership per document is operationally heavy.
Additional context
Existing infrastructure that supports this
The amplify-graphql-auth-transformer already has patterns that are close to what's needed:
-
protectRelationalResolver() already looks up a different model's ACM and generates cross-model auth VTL using ctx.source. For the relational traversal use cases (6, 7), this function needs a new branch — not a new mechanism.
-
The pipeline slot architecture (init → preAuth → auth → postAuth → preDataLoad → DynamoDB → postDataLoad → finish) naturally accommodates an additional auth step in the auth slot for the direct-query use cases (1-5).
-
generateAuthOnRelationalModelQueryExpression() already validates owner claims against fields flowing from a parent record through ctx.source. The VTL patterns for claim comparison exist.
Validation rules the transformer should enforce
throughRelation must reference a field that has a belongsTo relationship
- The referenced parent model must have at least one owner-based auth rule
throughRelation cannot be combined with other auth strategies on the same model (it's all-or-nothing inheritance)
- Circular
throughRelation chains must be detected and rejected
- The
belongsTo FK field must be required (otherwise the parent lookup can fail silently)
Scope and breaking changes
- This is a new authorization strategy — no existing behavior changes
- The
@auth directive SDL gains one new optional field (throughRelation: String) on AuthRule
- The
data-schema package gains one new builder method on the allow object
- All existing schemas continue to work identically
Is this something that you'd be interested in working on?
Would this feature include a breaking change?
Describe the feature you'd like to request
In collaborative applications, authorization is defined on a parent record (e.g., a
Documentwithowner,editors,viewersfields) and child records (e.g.,DocumentBlock) should inherit those permissions. Today, the only way to achieve this is duplicatingownersDefinedInarrays on every child model and keeping them in sync — either manually or via a Lambda-based cascade.This is fragile, expensive, and error-prone:
(number of child records) × (permission change frequency)instead of being constantCurrent schema (with duplication):
This pattern is common in any multi-user application: project management tools (Project → Task), document editors (Document → Block), shared notebooks (Notebook → Cell), CRM systems (Account → Contact), etc.
Describe the solution you'd like
A new authorization strategy
throughRelationthat tells the auth transformer: "this model's permissions are derived from a parent model's auth rules via abelongsTorelationship."Proposed schema:
At transform time, the auth transformer follows the
belongsTorelationship fromDocumentBlock.document→Document, reads Document's auth rules, and generates resolvers that resolve the calling user's allowed operations (not roles) from the parent record.At runtime, the generated resolver:
ctx.source)owner,editors,viewers).to([...])definitionsThe child model has no opinion about roles. The developer defines roles and their operation mappings once on the parent model. Child models just say "check the parent."
Use cases and expected resolver behavior
1. Direct query — get a single child record
The generated resolver must:
documentIddocumentIdctx.identity.subagainst Document'sowner/editors/viewersreadpermission → return the block$util.unauthorized()2. Direct query — list child records by parent
The generated resolver must:
"doc-abc"in theauthpipeline slotread→ proceed with DynamoDB Query on the GSI$util.unauthorized()3. Create a child record
The generated resolver must:
"doc-abc"using thedocumentIdfrom the mutation inputcreate→ proceed$util.unauthorized()4. Update a child record
The generated resolver must:
documentIddocumentIdupdate→ proceed$util.unauthorized()5. Delete a child record
Same as update — fetch existing record to get
documentId, then check parent fordeletepermission.6. Relational traversal — parent selectionSet includes children (list)
The
Document.blocksfield resolver fires.ctx.sourcealready contains the full Document record includingowner,editors,viewers. The generated resolver:ctx.identity.subagainstctx.source.owner/ctx.source.editors/ctx.source.viewersread→ proceed with the relational query$util.unauthorized()ctx.source7. Relational traversal — parent selectionSet includes children (get)
Same as use case 6 —
ctx.sourceis available, no extra read needed.Runtime operation resolution (not role-based)
The resolver does not expose or communicate roles. It resolves operations:
If the developer later adds
allow.ownersDefinedIn("commenters").to(["read", "create"])on Document, every child model withthroughRelation("document")automatically inherits this on the next deployment — zero changes to child models.Describe alternatives you've considered
1. Duplicating
ownersDefinedInarrays on every child model (current approach)Works but requires a Lambda function to cascade permission changes to all child records. Has race conditions during the sync window, scales writes linearly with child record count, and makes the schema definition repetitive and error-prone. See the "current schema" example above.
2. Lambda authorizer (
allow.custom())A Lambda function as the AppSync authorizer. The Lambda receives the
authorizationTokenandrequestContext, parses the GraphQL query to find thedocumentId, fetches the parent, and checks permissions.Problems:
3. Custom queries/mutations with pipeline resolvers
Disable auto-generated CRUD via
.disableOperations(), define custom queries/mutations witha.handler.custom()pipeline resolvers that do the parent lookup. This is architecturally sound but:client.models.X.create()toclient.queries.createX()4.
@authwithgroupsDefinedInpointing to a Cognito group per documentCreate a Cognito group per document and assign users to it. This doesn't scale — Cognito has a limit of 10,000 groups per user pool and managing group membership per document is operationally heavy.
Additional context
Existing infrastructure that supports this
The
amplify-graphql-auth-transformeralready has patterns that are close to what's needed:protectRelationalResolver()already looks up a different model's ACM and generates cross-model auth VTL usingctx.source. For the relational traversal use cases (6, 7), this function needs a new branch — not a new mechanism.The pipeline slot architecture (
init → preAuth → auth → postAuth → preDataLoad → DynamoDB → postDataLoad → finish) naturally accommodates an additional auth step in theauthslot for the direct-query use cases (1-5).generateAuthOnRelationalModelQueryExpression()already validates owner claims against fields flowing from a parent record throughctx.source. The VTL patterns for claim comparison exist.Validation rules the transformer should enforce
throughRelationmust reference a field that has abelongsTorelationshipthroughRelationcannot be combined with other auth strategies on the same model (it's all-or-nothing inheritance)throughRelationchains must be detected and rejectedbelongsToFK field must be required (otherwise the parent lookup can fail silently)Scope and breaking changes
@authdirective SDL gains one new optional field (throughRelation: String) onAuthRuledata-schemapackage gains one new builder method on theallowobjectIs this something that you'd be interested in working on?
Would this feature include a breaking change?