Skip to content

Feature Request: throughRelation authorization — inherit parent model auth rules on child models #3447

Description

@cabcookie

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.documentDocument, 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:

  1. Determines the parent record (via FK or ctx.source)
  2. Checks the user's identity against the parent's auth fields (owner, editors, viewers)
  3. Resolves which CRUD operations the user is allowed based on the parent's .to([...]) definitions
  4. 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?

  • 👋 I may be able to implement this feature request

Would this feature include a breaking change?

  • ⚠️ This feature might incur a breaking change

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions