Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC - @auth directive improvements #455

Open
mikeparisstuff opened this issue Mar 14, 2019 · 95 comments
Open

RFC - @auth directive improvements #455

mikeparisstuff opened this issue Mar 14, 2019 · 95 comments

Comments

@mikeparisstuff
Copy link
Contributor

mikeparisstuff commented Mar 14, 2019

RFC - @auth directive improvements

This document will outline designs for a number of new features relating to authentication & authorization within the GraphQL Transform. The goal of these features is too fill holes and introduce new mechanisms that make protecting your valuable information easier.

Proposal 1: Replace 'queries' and 'mutations' arguments with 'operations'

Merged by aws-amplify/amplify-cli#1262

Currently an @auth directive like this:

type Task @model @auth(rules: [{allow: owner}]) {
    id: ID!
    title: String
    owner: String
}

causes these changes to the following resolvers:

  1. Query.getTask - Returns the post only if the logged in user is the post's owner.
  2. Query.listTasks - Filter items such that only owned posts are returned.
  3. Mutation.createTask - If an owner is provided via $ctx.args.input.owner and matches the identity of the logged in user, succeed. If no owner is provided, set logged in user as the owner, else fail.
  4. Mutation.updateTask - Append a conditional expression that will only update the record if the logged in user is its owner.
  5. Mutation.deleteTask - Append a conditional expression that will only delete the record if the logged in user is its owner.

In other words, the @auth directive currently protects the root level query & mutation fields that are generated for an @model type.

Problem: The 'queries' and 'mutations' arguments imply top level protection

GraphQL APIs are a graph and we need to be able to define access rules on any field, not just the top level fields.

Solution

I suggest replacing the queries and mutations arguments on the @auth directive with a single operations argument. This would be the new @auth directive definition.

directive @auth(rules: [AuthRule!]!) on OBJECT
input AuthRule {
    allow: AuthStrategy!
    ownerField: String # defaults to "owner"
    identityField: String # defaults to "cognito:username"
    groupsField: String
    groups: [String]

    # The new argument
    operations: [ModelOperation]

    # Old arguments
    queries: [ModelQuery] @deprecated(reason: "The 'queries' argument will be deprecated in the future. Please replace this argument with the 'operations' argument.")
    mutations: [ModelMutation] @deprecated(reason: "The 'mutations' argument will be deprecated in the future. Please replace this argument with the 'mutations' argument.")
}
enum AuthStrategy { owner groups }

# The new enum
enum ModelOperation { create update delete read }

# The old enums
enum ModelQuery { get list }
enum ModelMutation { create update delete }

This change generalizes the config such that it implies all read operations on that model will be protected. Not just the top level 'get' & 'list' queries. Auth rules that use the 'read' operation will be applied to top level query fields, @connection resolvers, top level fields that query custom indexes, and subscription fields. Auth rules that use the 'create', 'update', and 'delete' operation will be applied to createX, updateX, and deleteX mutations respectively. Those using queries & mutations will have the same behavior and those using operations will get the new behavior. The queries & mutations arguments will eventually be removed in a future major release.

Protect @connections by default

Merged by aws-amplify/amplify-cli#1262

Once the change from queries/mutations -> operations has been implemented, we will want to go back and implement any missing authorization logic in @connection fields by default.

For example, given this schema:

type Post @model @auth(rules: [{allow: owner}], operations: [create, update, delete, read]) {
    id: ID!
    title: String
    owner: String
}
type Blog @model {
    id: ID!
    title: String
    # This connection references type Post which has auth rules and thus should be authorized.
    posts: [Post] @connection
}

The new code would add authorization logic to the Blog.posts resolver such that only owner's of the post would be able to see the posts for a given blog. It is important to note that the new logic will restrict access such that you cannot see records that you are not supposed to see, but it will not change any index structures under the hood. You will be able to use @connection with the new custom index features to optimize the access pattern and then use @auth to protect access within that table or index.

Proposal 2: Implement @auth on @searchable search fields

Github Issues

Problem

Currently Query.searchX resolvers generated by @searchable are not protected by @auth rules.

Solution

The Elasticsearch DSL is very powerful and will allow us inject Elasticsearch query terms and implement authorization checks within Elasticsearch. This work will need to handle static & dynamic ownership and group based authorization rules. Any auth rule that includes the 'read' operation will protect the Query.searchX field.

Proposal 3: Make @auth protect subscription fields

Problem: @auth does not protect subscription fields.

type Post @model @auth(rules: [{allow: owner}]) {
    id: ID!
    title: String
    owner: String
}

Currently subscriptions are not protected automatically.

Solution

AppSync subscription queries are authorized at connect time. That means that we need to parameterize the subscription queries in such a way that any relevant authorization logic is included in the subscription query itself. In the case of ownership @auth, this means that the client must pass an owner as a query argument and the subscription resolver should verify that the logged in user and owner are the same.

For example, given this schema:

type Post @model @auth(rules: [{allow: owner}]) {
    id: ID!
    title: String
    owner: String
}

The following subscription fields would be output:

type Subscription {
    onCreatePost(owner: String): Post
    onUpdatePost(owner: String): Post
    onDeletePost(owner: String): Post
}

and when running a subscription query, the client must provide a value for the owner:

subscription OnUpdatePost($owner: String) {
    onUpdatePost(owner: $owner) {
        id
        title
    }
}

The proposed change would create a new subscription resolver for each subscription field generated by the @model. Each subscription resolver would verify the provided owner matches the logged-in identity and would fail the subscription otherwise.

There are a few limitation to this approach:

  1. There is a limit of 5 arguments per subscription field.
    • e.g. a field onCreatePost(owner: String, groups: String, otherOther: String, anotherOwner: String, anotherListOfGroups: String): Post has too many fields and is invalid. To handle this the CLI can emit a warning prompting you to customize your subscription field in the schema itself.
  2. Subscription fields are equality checked against published objects. This means that subscribing to objects with with multi-owner or multi-group auth might behave slightly differently than expected.
    • When you subscribe you will need to pass the full list of owners/groups on the item. Not just the calling identity.

As an example to point (2) above, imagine this auth rule:

type Post @model @auth(rules: [{allow: owner, ownerField: "members"}]) {
    id: ID!
    title: String
    members: [String]
}

Let's say that we want to subscribe to all new posts where I am a member.

subscription {
    onCreatePost(members: ["my-user-id"]) {
        id
        title
        members
    }
}

AppSync messages are published to subscriptions when the result of the mutation, to which the subscription field is subscribed, contains fields that equal the values provided by the subscription arguments. That means that if I were to publish a message via a mutation,

mutation {
    createPost(input: { title: "New Article", members: ["my-user-id", "my-friends-user-id"]}) {
        id
        title
        members
    }
}

the subscription started before would not be triggered because ["my-user-id", "my-friends-user-id"] is not the same as ["my-user-id"]. I bring this up for clarity but I still think this feature is useful. Single owner & group based authorization will behave as expected.

Proposal 4: Field level @auth

Merged by aws-amplify/amplify-cli#1262

Currently an @auth directive like this:

type Task @model @auth(rules: [{allow: owner}], queries: [get, list], mutations: [create, update, delete]) {
    id: ID!
    title: String
    owner: String
}

causes these changes to the following resolvers:

  1. Query.getTask - Returns the post only if the logged in user is the post's owner.
  2. Query.listTasks - Filter items such that only owned posts are returned.
  3. Mutation.createTask - If an owner is provided via $ctx.args.input.owner and matches the identity of the logged in user, succeed. If no owner is provided, set logged in user as the owner, else fail.
  4. Mutation.updateTask - Append a conditional expression that will only update the record if the logged in user is its owner.
  5. Mutation.deleteTask - Append a conditional expression that will only delete the record if the logged in user is its owner.

In other words, the @auth directive currently protects the root level query & mutation fields.

Github Issues

Problem: You cannot protect @connection resolvers

For example, look at this schema.

type Task @model {
    id: ID!
    title: String
    owner: String
    notes: [Task] @connection(name: "TaskNotes")
}
# We are trying to specify that notes should only be visible by the owner but
# we are unintentially opening access via *Task.notes*.
type Notes @model @auth(rules: [{allow: owner}]) {
    id: ID!
    title: String
    task: Task @connection(name: "TaskNotes")
    owner: String
}

Since only top level fields are protected and we do not have an @auth directive on the Task model, we are unintentionally opening access to posts via Task.notes.

Solution

We discussed having @auth rules on OBJECTs automatically protect connection fields in proposal 1, but I also suggest opening the @auth directive such that it can be placed on both FIELD_DEFINITION and OBJECT nodes. This will result in an updated definition for @auth:

directive @auth(rules: [AuthRule!]!) on OBJECT, FIELD_DEFINITION
# ...

You may then use the @auth directive on individual fields in addition to the object type definition. An @auth directive used on an @model OBJECT will augment top level queries & mutations while an @auth directive used on a FIELD_DEFINITION will protect that field's resolver by comparing the identity to the source object designated via $ctx.source.

For example, you might have:

type User @model {
    id: ID!
    username: String
    
    # Can be used to protect @connection fields.
    # This resolver will compare the $ctx.identity to the "username" attribute on the User object (via $ctx.source in the User.posts resolver).
    # In other words, we are authorizing access to posts based on information in the user object.
    posts: [Post] @connection(name: "UserPosts") @auth(rules: [{ allow: owner, ownerField: "username" }])

    # Can also be used to protect other fields
    ssn: String @auth(rules: [{ allow: owner, ownerField: "username" }])
}
# Users may create, update, delete, get, & list at the top level if they are the
# owner of this post itself.
type Post @model @auth(rules: [{ allow: owner }]) {
    id: ID!
    title: String
    author: User @connection(name: "UserPosts")
    owner: String
}

An important thing to notice is that the @auth directives compares the logged-in identity to the object exposed by $ctx.source in the resolver of that field. A side effect of (1) is that an @auth directive on a field in the top level query type doesn't have much meaning since $ctx.source will be an empty object. This is ok since @auth rules on OBJECT types handle protecting top level query/mutation fields.

Also note that the queries and mutations arguments on the @auth directive are invalid but the operations argument is allowed. The transform will validate this and fail at compile time with an error message pointing you to the mistake. E.G. this is invalid:

type User @model {
    # @auth on FIELD_DEFINITION is always protecting a field that reads data.
    # Fails with error "@auth directive used on field User.ssn cannot specify arguments 'mutations' and 'queries'"
    ssn: String @auth(rules: [{allow: owner}, mutations: [create], queries: [get]]) 

    # No one but the owner may update/delete/read their own email.
    email: String @auth(rules: [{allow: owner}, operations: [update, delete, read]) 
}

The implementation for allowing operations in field level @auth directives is a little different.

  1. create - When placed on a @model type, will verify that only the owner may pass the field in the input arg. When used on a non @model type, this does nothing.
  2. update - When placed on a @model type, will verify that only the owner may pass the field in the input arg. When used on a non @model type, this does nothing.
  3. delete - When placed on a @model type, will verify that only the owner may set the value to null. Only object level @auth directives impact delete operations so this will actually augment the update mutation and prevent passing null if you are not the owner.
  4. read - Places a resolver on the field (or updates an existing resolver in the case of @connection) that restricts access to the field. When used on a non-model type, this still protects access to the resolver.

Proposal 5: And/Or in @auth rules

Github Issues

Problem

Currently all @auth rules are joined via a top level OR operation. For example, the schema below results in rules where you can access Post objects if you are the owner OR if you are member of the "Admin" group.

type Post @model @auth(rules: [{ allow: owner }, { allow: groups, groups: ["Admin"] }]) {
    id: ID!
    title: String
    author: User @connection(name: "UserPosts")
    owner: String
}

It would be useful if you could organize these auth rules using more complex rules combined with AND and OR.

Solution

We can accomplish this by adding to the the @auth definition.

directive @auth(rules: [TopLevelAuthRule!]!) on OBJECT, FIELD_DEFINITION
input TopLevelAuthRule {
    # For backwards compat, any rule specified at the same level as an "and"/"or" will be joined via an OR.
    allow: AuthStrategy!
    ownerField: String # defaults to "owner"
    identityField: String # defaults to "cognito:username" for UserPools, "username" for IAM, "sub" for OIDC
    groupsField: String
    groups: [String]
    
    # This only exists in top level rules and specifies operations for all the rules even when combined with and/or.
    # Neseted "operations" tags are not allowed because it would confuse evaluation logic.
    operations: [ModelOperation]

    # New recursive fields on AuthRule
    and: [AuthRule]
    or: [AuthRule]   
}
input AuthRule {
    allow: AuthStrategy!
    ownerField: String # defaults to "owner"
    identityField: String # defaults to "cognito:username" for UserPools, "username" for IAM, "sub" for OIDC
    groupsField: String
    groups: [String]

    # New recursive fields on AuthRule
    and: [AuthRule]
    or: [AuthRule]
}
enum AuthStrategy { owner groups }
# Reduces get/list to read. See explanation below.
enum ModelOperation { create update delete read }

This would allow users to define advanced auth configurations like:

type User
  @model 
  @auth(rules: [{
    and: [
      { allow: owner },
      { or: [
        { allow: groups, groups: ["Admin"] },
        { allow: owner, ownerField: ["admins"] },
      }
    ],
    operations: [read]
  }]) {
  id: ID!
  admins: [String]
  owner: String
}
# Logically: ( isOwner && ( isInAdminGroup || isMemberOfAdminsField ) )

The generated resolver logic will need to be updated to evaluate the expression tree.

Proposal 6: Deny by default mode.

Github Issues

Problem: There is currently no way to specify deny access by default for Amplify APIs.

If you create an API using a schema:

type Post @model {
    id: ID!
    title: String!
}

then the generated create, update, delete, get, and list resolvers allow access to any request that includes a valid user pool token (for USER_POOL auth). This proposal will introduce a flag that specifies that all operations should be denied by default and thus all fields that do not contain an explicit auth rule will be denied. This will also change the behavior of create mutations such that the logged in user identity is never added automatically when creating objects with ownership auth.

Solution: Provide a flag that enables deny by default

By adding a DenyByDefault flag to parameters.json or transform.conf.json will allow users to specify whether fields without an @auth directive will allow access or not. When deny by default is enabled the following changes will be made.

  1. Mutation.createX resolvers will no longer auto-inject the ownership credential when the ownership credential is not provided when creating objects. Users will have to supply the ownership credential from the client and it will be validated in the mutation resolver (this happens already when you provide the ownership credential in the input).
  2. All resolvers created by a @model without an @auth directive will be denied by default.
  3. All resolvers created by @searchable on a @model without an @auth will be denied by default.
  4. All resolvers created by @connection that return a @model with an @auth AND that do not have their own field level @auth will be denied by default.

For example, with deny by default enabled

type Post @model {
    id: ID!
    title: String
    author: User @connection(name: "UserPosts")
    comments: [Comment] @connection(name: "PostComments")
}
type User @model @auth(rules: [{allow: owner}]) {
    id: ID!
    username: String!
    posts: [Post] @connection(name: "UserPosts")
}
type Comment @model {
    id: ID!
    content: String
    post: [Comment] @connection(name: "PostComments")
}

This mutation would fail:

mutation CreatePost {
    createPost(...) {
        id
        title
    }
}

This mutation would succeed:

mutation CreateUser {
    createUser(input: { username: "me@email.co" }) { # Assuming the logged in user identity is the same
        id
        title
    }
}

This top level query would succeed but Post.comments would fail.

query GetUserAndPostsAndComments {
    getUser(id: 1) { # succeeds assuming this is my user.
        posts { # succeeds because the @auth on User authorizes the child fields
            items {
                title
                comments { # fails because there is no auth rule on Post, Comment, or the Post.comments field.
                    items {
                        content
                    }
                }
            }
        }
    }
}

More details coming soon

  1. Write custom auth logic w/ pipeline functions
  2. Enable IAM auth within the API category

Request for comments

This document details a road map for authorization improvements in the Amplify CLI's API category. If there are use cases that are not covered or you have a suggestion for one of the proposals above please comment below.

@jkeys-ecg-nmsu
Copy link

You will be able to use @connection with the new custom index features to optimize the access pattern and then use @auth to protect access within that table or index.

Loving it.

The proposed change would create a new subscription resolver for each subscription field generated by the @model. Each subscription resolver would verify the provided owner matches the logged-in identity and would fail the subscription otherwise.

Fantastic!

You may then use the @auth directive on individual fields in addition to the object type definition. An @auth directive used on an @model OBJECT will augment top level queries & mutations while an @auth directive used on a FIELD_DEFINITION will protect that field's resolver by comparing the identity to the source object designated via $ctx.source

I'm drooling a bit at this point...

This proposal will introduce a flag that specifies that all operations should be denied by default and thus all fields that do not contain an explicit auth rule will be denied. This will also change the behavior of create mutations such that the logged in user identity is never added automatically when creating objects with ownership auth.

Thank you please.

Fantastic proposals! I think the level of granular control this will enable will make it easier to maintain regulatory compliance (e.g. HIPAA, PCI) with the graphql-transformer and generally make schema design simpler. Looking forward to seeing it in production.

@chrisco512
Copy link

Great write up and love the proposals. One big area is the idea of multi-tenancy and not relying on Cognito Groups but "tenantId" or some other arbitrary field to determine tenant ownership. This might be covered by enabling custom Auth logic with pipeline functions. This is how I'm solving it currently, but it's a manual process and a directive to cover this case would be nice!

@blazinaj
Copy link

The "Deny by default" mode would be perfect. For a secure multi-tenant app I would rather have all of my queries/mutations protected by default and need to be explicitly opened up for a user/group, so that a schema/resolver mistake on my end doesn't automatically mean compromised user data.

@amirmishani
Copy link

These are all fantastic and I can't wait for them to be implemented. I think proposal 1 needs to get implemented asap so we don't have to refactor too much. I also think proposal 5 is very important feature that's lacking from Amplify. Having to write pipeline resolvers + CFN for every operation because you need to verify the tenant sucks the joy out of my life!

@ajhool
Copy link

ajhool commented Apr 5, 2019

Please consider the aws-amplify/amplify-cli#805 issue. To summarize: it would be nice if the GraphQL endpoint created by Amplify could have cognito-level auth (eg. owner, groups/admin, etc.), but also be accessible by other AWS resources like Lambda.

Perhaps the auth directive could include a flag that would generate an IAM policy or IAM role that would allow other resources (eg. Lambda) to perform mutations and queries? The other role (eg. Lambda) would need to be responsible for setting the owner field correctly.

On one hand, I recognize that Amplify prefers to keep everything within its own ecosystem, however, it would be nice if there were simple hooks to allow other resources to interact with the Amplify stack and leverage the graphQL endpoint.

It seems like a common use-case that the user might create a DynamoDB object and then a backend system would update it or delete it.

@vparpoil
Copy link

vparpoil commented Apr 5, 2019

Proposal 3 to secure subscriptions is a must have for us (and I’m really curious to know if I could as of today us the console to attach a particular resolver to a subscription that would secure it).
Regarding proposal 4, I would like being able to define a field that is read only for certain users and writable for others. I am not sure that this proposal take this use case into account.

Finally, regarding @ajhool remarks right above, I would like to add that making the auth directive compatible with a setup with AWS_IAM would be great ! (for apps that have an auth/unauth setup)

Thank you @mikeparisstuff and the team, please keep going like this, we love amplify :) !

@anarerdene
Copy link

Allow Owner not working On IAM ...

mikeparisstuff referenced this issue in mikeparisstuff/amplify-cli Apr 12, 2019
…auth support via the @auth dire

Adding support for field level authorization via the @auth directive.

re #1043

Cleaning up tests
@mikeparisstuff
Copy link
Contributor Author

Proposals 1 & 5 implemented in aws-amplify/amplify-cli#1262

@jkeys-ecg-nmsu
Copy link

Proposal 3 would help companies working with Amplify/AppSync subscriptions maintain compliance (PCI, HIPAA, privacy, etc) more easily. Please consider prioritizing it for your next iteration!

mikeparisstuff referenced this issue in aws-amplify/amplify-cli Apr 15, 2019
…rective (#1262)

* feat(@auth directive transformer and e2e tests.): Adding field level auth support via the @auth dire

Changes:
- Added the 'operations' argument to the AuthRule input used by the @auth directive.
- Made backwards compatible changes such that @auth directives that use the 'operations' argument protect @connection resolvers.
- Added support for field level @auth directives. Protect query resolvers on any type and mutations on @models.
- Many new e2e tests for field level authorization checks.
- Refactor existing tests to make debugging and coverage easier to understand.

re #1043
@et304383
Copy link

et304383 commented May 2, 2019

@mikeparisstuff If it's not too late, you REALLY need to change auth owner validation to be based on the cognito sub and not the username.

Usernames can be reassigned, and if that happens, you could have a potential situation where person can access a record owned by the person who used to own that record.

The sub value does not change, ever, and should be the unique identifier for validation of ownership.

AWS response explains this pretty clearly:
https://forums.aws.amazon.com/thread.jspa?threadID=243796

@zfarrell
Copy link

zfarrell commented May 2, 2019

@mikeparisstuff - I'm wondering if Proposal 5 could be expanded to support conditions more complex than just and/or?

A particular use case I'd love to see @auth support, is arbitrary field value comparison.

For example, i have a model (let's call it "node") which has various states: ["start","middle","end"]. I want user's to only have access to certain nodes, based on the node's state and the group they belong to.

Rather than a static and/or field, something like condition could be used to future proof, which could encapsulate any possible operator (which could be rolled out over time): and, or, eq, lt, gt, in, etc.

type Node
    @model
    @auth(rules: [
      // allow Admins full access
      { allow: groups, groups: [ "Admin" ] },
      
      // allow employees in group "pre-process" to access Node's in state "start"
      // Example 1 - static comparison. explicitly set group name, and expected value of `state` field
      { allow: groups, groups: ["pre-process"],
        // -- here's an expansion on the and/or logic proposed in "Proposal 5"
        // note the use of `condition`, `eq`, `field`, `val`
        condition: {
          eq: [
            // value of `state` field must equal 'pre-process'
            { field: 'state' },
            { val: 'pre-process'}
          ]
        },
    
      // Example 2 - dynamic comparison.
      {
        allow: groups,
        condition: {
          eq: [
            // value of `state` field, must equal value of groupField concatenated to 'state-processors-'
            // e.g. someone in group 'state-processors-start' could access nodes with state `start`
            { field: 'state' },
            { join: [
                // static value
                { val: 'state-processors-'},
                // variable reference, in this case it's the value of the groupInput
                { ref: 'group' }
              ]
            }
          ]
        }
      }
    ]) {
    
    id: ID!
    state: State!
}


enum State {
    Start,
    Middle,
    End
}

This is pretty rough - but just sketching out a possible solution. I also can see the case for this creating too much complexity, but the only alternative right now (that I see) seems to be writing a custom resolver for every model, which is complex in it's own way.

@ajhool
Copy link

ajhool commented May 6, 2019

A common use case that is currently a little bit awkward is a DynamoDB object where each user should have only one (eg. UserSettings). I usually implement that setting the id field to the owner field. It would be really nice if the Get resolver recognized that the ownerField was also the ID field and returned the object. I believe that similar logic is used in the list query when { allow: owner, operations: [get, list ] }, where the resolver queries on the ownerField.

For instance:

type UserSettings @model
@auth(rules: [
    { allow: owner, operations: [get, update] },
    { allow: groups, groups: ["Admin"] }
]) {
  owner: ID
  favoriteColor: String!
}

I believe the 2 current ways to achieve this query are:
a.

// must add "list" to the auth model operations
const result = await API.graphql(graphqlOperation(queries.listUserSettings));
const { items } = result.listUserSettings;
const userSettings = (items.length === 1) ? items[0] || null;
If( null === userSettings) {
   throw 'No user settings found'
}

b.

 // I'm not sure if the id field is the field that amplify chooses for "owner"
 const { id } = await Auth.currentAuthenticatedUser();
 await API.graphql(graphqlOperation(queries.getUserSettings, { owner: id }));

Neither of those are bad at all and this definitely isn't high priority, but it would be nice to have in the backend and it is common enough that it would make sense to support natively IMHO

@ajhool
Copy link

ajhool commented May 8, 2019

Two comments related to rule 6. Both comments advocate for an explicit AUTHENTICATED or PUBLIC group that can be used to explicitly state auth strategies.

First:

The allow by default with an optional deny by default FLAG is the design pattern that caused so many data leaks with public S3 buckets. Just make everything explicit and stop adding implicit public auth strategies. Any tiny amount of time/effort saved by using allow by default is negated by the inevitable data leaks caused by accidentally granting public write access to every object.

For instance:

// BAD: Throw error
type SomePublicItem @model
  {
    id: ID!
    name: String
  }

-> ERROR: Please provide an auth strategy for SomePublicItem
// GOOD: Do not throw error. Although, frankly, I'm not sure what API EVER has a totally open public CRUD item like this.
type SomePublicItem @model
  @auth(rules: [ { allow: groups, groups: [ _PUBLIC_ ] } ] )
  {
    id: ID!
    name: String
  }


Second:

Another common use case that I don't think is currently possible with the @auth directive (please correct me if I'm wrong, I'd really like to use it!) is to explicitly provide public/unauthenticated/authenticated read permissions. I would consider this to be a better implementation of Rule 6 by simply making everything explicit.

A persisted object that an Admin creates/updates, but that everybody should be able to get/list. It would be nice if there were default groups named EVERYBODY or UNAUTHENTICATED or AUTHENTICATED or ALL.

type Product @model
  @auth(rules: [ 
    { allow: groups, groups: ["Admin"] },
    { allow: groups, groups: [ _PUBLIC_, __UNAUTHENTICATED__ ], operations: [ get, list ] }
  ])
  {
    id: ID!
    name: String
    description: String
  }

It seems unnecessarily complex that we would create a Cognito group called "everybody" or "customers" and automatically add all users to that group when they sign up, but maybe that is the better design pattern?

AlessandroVol23 referenced this issue in AlessandroVol23/amplify-cli May 16, 2019
…rective (aws-amplify#1262)

* feat(@auth directive transformer and e2e tests.): Adding field level auth support via the @auth dire

Changes:
- Added the 'operations' argument to the AuthRule input used by the @auth directive.
- Made backwards compatible changes such that @auth directives that use the 'operations' argument protect @connection resolvers.
- Added support for field level @auth directives. Protect query resolvers on any type and mutations on @models.
- Many new e2e tests for field level authorization checks.
- Refactor existing tests to make debugging and coverage easier to understand.

re #1043
@jmorecroft
Copy link

Hi folks, might be the wrong thread or place for this but somewhat related - can anyone explain to me the rationale for choosing Cognito:username as the implicit "owner" if none is provided, as implemented in the auto gen'ed resolvers, as opposed to the identity's "sub"? My guess is one's as good as the other and the former is more readable, but I do wonder about the impact of username changing/reassignment etc.

Googling a little on this pulled up this thread for example, which suggests that "sub" would be a much more resilient identifier for auth? For example, if a user is deleted and another created with the same username, the current auth means they'll get access to all the first user's entities..?

https://stackoverflow.com/questions/39223347/should-i-use-aws-cognito-username-or-sub-uid-for-storing-in-database

@et304383
Copy link

@jmorecroft it's purely a mistake on the Amplify team's part. The sub should always be used to uniquely identify a user. I already posted about this here in this same thread.

@ajhool
Copy link

ajhool commented May 27, 2019

@jmorecroft @et304383 I had posted a similar bug/bad design decision (in the amplify js repo), where the Storage object is connected to a transient id: aws-amplify/amplify-js#1787 , not sure if that is still the case.

@BeaveArony
Copy link

BeaveArony commented May 28, 2019

Protecting a @connection field by queries is done by using this pattern:

type User @model {
    id: ID!
    username: String
    
    posts: [Post] 
      @connection(name: "UserPosts") 
      @auth(rules: [{ allow: owner, ownerField: "username" }])
}
type Post @model(queries: null) { ... }

How can we protect, that someone else adds a new Post to any other User?

Say I'm logged in as 'Cersei' and do the following mutation:

mutation addPostToOtherUser {
  createPost(input: { title:"My evil Post", userPostId:"Daenerys"} ) {...}
}

I would like to protect against this.

Edit:

Never mind it works quite well when I do something like this:

type User @model @auth(rules: [{ allow: owner, ownerField: "id" }]) {
    id: ID!
    posts: [Post] 
      @connection(name: "UserPosts") 
      @auth(rules: [{ allow: owner, ownerField: "id" }])
}

type Post @model(queries: null) { 
  title: String!
  user: User!
      @connection(name: "UserPosts") 
      @auth(rules: [{ allow: owner, ownerField: "userPostId" }])
}

If I save the username as id in the type User, it will be used as the userPostId in the @connection of the user field in type Post and block the creation of the Post when the username does not match the identity.

@leetmachine
Copy link

I'm using the new @auth 'operations' argument to achieve:

  1. allow read (get/list) access to any unauthenticated or authenticated user.
  2. allow only an authenticated user to create, update, delete.

my setup:
{allow: owner, operations: [create, update, delete]}

Outcome:

  1. allow read (get/list) access to any unauthenticated or authenticated user.
    -- it does work for authenticated user.
    --it does NOT work for unauthenticated user. error: "no current user".

  2. allow only an authenticated user to create, update, delete.
    -- it does work for authenticated user, allow to perform operations.
    -- it does work for unauthenticated user, does not allow to create an object.

Since `{allow: owner} will impose OwnerStrategy for ALL operations, I would expect my setup to exclude the read operation from the OwnerStrategy. Because it is excluded I would expect it to function just as it would without the OwnerStrategy. Regardless of a user or not, it should produce the read.

@markau
Copy link

markau commented Jun 4, 2019

I am interested in Proposal 5 (and/or auth rules), as I would like to have a group membership override the owner priveleges. For example, a banned user (still owner of a post, but not currently allowed to edit/delete due to being in the "Banned Users" group).

@hisham
Copy link

hisham commented Jun 12, 2019

Can @auth support be extended to S3Objects (storage) category? Last time I checked the storage category (https://github.com/aws-amplify/amplify-cli/blob/master/packages/amplify-category-storage/Readme.md) creates 3 folders - private, protected, and public. Objects in private are only accessible to the user who uploaded them. Protected is accessible to any authenticated user, and public is public access.

The authentication for this is also through identity management I believe, not cognito sub.

It seems kind of divorced from the @auth rules set in the GraphQL schema, though access to the S3Object stored in dynamodb is controlled by the @auth rules. The actual objects stored in S3 are not. This is a point of confusion and perhaps can be considered as part of this RFC or aws-amplify/amplify-cli#766.

From my high level point of view, data in S3 should be controlled under the same access rules set by this @auth directive.

@blazinaj
Copy link

Can @auth support be extended to S3Objects (storage) category? Last time I checked the storage category (https://github.com/aws-amplify/amplify-cli/blob/master/packages/amplify-category-storage/Readme.md) creates 3 folders - private, protected, and public. Objects in private are only accessible to the user who uploaded them. Protected is accessible to any authenticated user, and public is public access.

The authentication for this is also through identity management I believe, not cognito sub.

It seems kind of divorced from the @auth rules set in the GraphQL schema, though access to the S3Object stored in dynamodb is controlled by the @auth rules. The actual objects stored in S3 are not. This is a point of confusion and perhaps can be considered as part of this RFC or aws-amplify/amplify-cli#766.

From my high level point of view, data in S3 should be controlled under the same access rules set by this @auth directive.

+1 for this. I have a use case that I need to control S3 access based on the same Cognito/Auth Groups that govern my API access. I need Admin level access, group level access, or individual level access based on logged in user. I'm trying to set up S3 Bucket policies based on Cognito User Pool groups but I'm having to do it manually at the moment.

@ajhool
Copy link

ajhool commented Jul 10, 2019

Wait a second, per item number 3, where "Currently subscriptions are not protected automatically." does this mean that protected data can leak to anybody who subscribes to a data type? (issue also referenced, here: aws-amplify/amplify-cli#1766 )

Let's say the owner of the website defines their auth module as follows:

type Todo @model @auth(rules: [{ allow: owner }]) {

And then I go to their website and run this code from their site (that developer has to be running the code from the client because amplify-js doesn't support server side execution).

import Amplify, { API, graphqlOperation } from 'aws-amplify';
import * as subscriptions from './graphql/subscriptions';

// Subscribe to creation of Todo
const subscription = API.graphql(
    graphqlOperation(subscriptions.onCreateTodo)
).subscribe({
    next: (todoData) => console.log(todoData)
});

// Stop receiving data updates from the subscription
subscription.unsubscribe();

Do I now have access to every single Todo object that gets created on their site? Even though the developer added the @auth rule for "owner" only with the clear goal of keeping that user's Todo (or, say, healthcare records) information private?

Please tell me that I'm misunderstanding something here because this makes a mockery of the efforts I've taken to secure my website if the Amplify team is aware of such a terrible vulnerability in the default configuration of the framework and doesn't make any effort to document it (or demonstrate any urgency to make it the non-default configuration), aside from a proposal in an RFC. If amplify were actually used by any serious companies in production, this would effectively be a zero-day vulnerability -- this isn't the "absence of a feature", it's an undocumented vulnerability in the default configuration

Why is there even an @auth directive provided if it doesn't currently prevent subscriptions from providing a firehose of data to anybody who wants it when used in the default configuration? And why is there no documentation suggesting that a non-default configuration is required? Proper documentation would have given me the opportunity to not use this framework before realizing these shortcomings so late in the game; I will be far more hesitant to depend on future AWS products if this is the amount of thought given to such a clear and ridiculous vulnerability.

Would anybody who uses amplify in production and protects valuable information please send me their website url?

@amirmishani
Copy link

amirmishani commented May 9, 2020

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

I'm still waiting to see if we can get Proposal 5 and have "And" in @auth rules. This is very important for multi-tenancy where you always need to check for tenant id AND other auth rules.

@mvogttech
Copy link

@amirmishani Same here. This is the last thing I am waiting for to release my app.

@edlefebvre
Copy link

Hi there, any update on Proposal 5 "And" in @auth rules? @mikeparisstuff ?

@aws-amplify aws-amplify deleted a comment from ivankokus Sep 16, 2020
@tomyitav
Copy link

Hi, is there anything new about granting read access for non-authenticated users?

@mir1198yusuf
Copy link

In proposal 3 - add a new sub-proposal or something -

the subscription started before would not be triggered because ["my-user-id", "my-friends-user-id"] is not the same as ["my-user-id"]. I bring this up for clarity but I still think this feature is useful. Single owner & group based authorization will behave as expected.

Single and group owner should behave as expected but sometimes we need a if-found condition. Add a new if-in-group owner. Allow subscriptions if a user is present in a member array is very frequently needed use case especially when the members list could be dynamic (users can be added or removed )

Example whatsapp application
aws-amplify/amplify-js#7534 (comment)

@kylekirkby
Copy link

kylekirkby commented Feb 12, 2021

I'm trying to create a YouTube-like resources hub where some sources are public and others are private.

If I use the current public @auth directive, all resources are then accesible to the public but only a subset of them should be.

For example:

type Resource
	@model
	@auth(
		rules: [
			{ allow: groups, groupField: "visibleTo", operations: [read] }
			{ allow: groups, groupField: "editors", operations: [update, read] }
			{ allow: groups, groups: ["admin"], operations: [create, update, read, delete] }
		]
	) {
	id: ID!
	title: String!
	description: String!
	videoUrl: String
	presentationUrl: String
	editors: [String]!
	visibleTo: [String]!
}

The above would look at the visibleTo array for the names of groups that have access to read the resources. The editors array would contain the names of Cognito groups that have access to edit resources. Admins can perform CRUD operations.

With the above example, how does one enable public access without writing countless custom resolvers/lambda functions?

There is no public group assigned to unauthenticated users that receive their credentials (without signing in to the app) via Auth.getCredentials().

Any suggestions on how we can enable public/private access that can change? It would be great if there was a way add a public group to the editors/viewers fields that resolves to public access.

Maybe the @auth directive can be updated so that:

@auth(rules: [{ allow: groups, groupField: "visibleTo", operations: [read], includePublicGroup: "true"}])

If includePublicGroup is set to "true" then the presence of a special group name applies public access rules. I.e ["PUBLIC_GROUP", "admins", "editors", "basic"].

@ryanhollander
Copy link

ryanhollander commented Feb 12, 2021

@kylekirkby I'm sorry I don't have time for more thorough research or a better answer. You should look at both the "custom claims" in the amplify cli graphql transformer documentation, and pre-token trigger in cognito. I used the latter to inject group claims for federated users that are linked to cognito identity pool users. I haven't tried this with public users, I suspect one of these features might be your best path.

@kylekirkby
Copy link

@ryanhollander - Thanks for your reply! I think the pre token trigger method would work as long as unauthenticated requests sent to app sync respect the custom identity claims. The pre token trigger function would have to inject a cognito:groups value of public for unauth users. I’ll have a play with it and see if I can get something working.

@ryanhollander
Copy link

@kylekirkby Good luck. FYI, You can use the Amplify CLI to create the trigger w/Lambda through the Auth Flow.

@kylekirkby
Copy link

@ryanhollander cheers! I used the cli to add a pre sign up trigger hoping to be able to link External Provider accounts with Cognito Users only to find that the AdminLinkUsers api function is broken :/

https://blog.ilearnaws.com/2020/08/06/error-cognito-auth-flow-fails-with-already-found-an-entry-for-username/

@ryanhollander
Copy link

@kylekirkby briefly, I just implemented this using the pre-sign-up trigger and adminLinkProviderForUser. I did this using the Auth Flow and a few lambdas. There were a lot of gotcha's! What I thought might take a morning took me almost 3 days. I did not experience this particular issue, though I read about it alot, it's possible the amplify js eats the issue or cognito was fixed, not sure. FWIW: I'm using Amplify's FederatedLogin.

What I ended up doing was a tad convoluted. The issue is that you can't give the trigger lambda the permissions for the user pool. This is because it would create a circular dependency in the build. The workaround is to create a second lambda, give it the auth permissions via the cli, and manually add the execute permissions to the second lambda for the trigger via the cloudformation json (because there is no dependency for this as IAM doesn't care if your resource exists). Let the trigger call the second lambda for any cognito functions. What I found was I needed two of these, a pre-sign-up to link the federated user to the pool user, and a pre-token to inject the group assignments from the linked cognito user into the token of the federated user. For this project I am using the user pool as the primary user store but users can only login with their domain email through a federated login (for now).

Anyway, I might get around to posting more detail and code on this at some point...

@kylekirkby
Copy link

@ryanhollander nice one for the write up! Sounds familiar! I’ve done the whole create a secondary lambda to be called from a post confirmation trigger in another project. I managed to get the pre signup trigger to work with wildcard permissions without needing to add to another lambda function. I know the full cognito pool arn should be used but that was a quick work around and I assumed that since the lambda is triggered directly from Cognito itself, it should be safe some what to assume the correct pull is used.

As for the @auth situation. I’m thinking it might be easier to separate out the PrivateResources into its own type. And give full public read access to a Resource type (public).

As for the storage of the videos in S3 I think I’m going to need to write a custom query to fetch a pre-signed url from s3 if the user belongs to a valid group for that resource.

@ryanhollander
Copy link

@kylekirkby with the amplify cli you can give the lambda the exact pool arn for each env with 0 overhead after you do the 1 custom config. Another type/model is always an option, both paths have advantages. I've done that signed URL to S3 scheme too. It's not hard to implement and works great. I think I pulled most of the code from a blog post somewhere.

@kylekirkby
Copy link

@ryanhollander how did you arrange the bucket for user uploads? Did you use the Amplify Storage feature and then set uploads to private?

@ryanhollander
Copy link

@kylekirkby I used the Amplify Storage and amplify.js on the front end, I store the reference in DynamoDB. I then use the aws sdk in a lambda to generate a signed URL with a 24 hour expiration and a custom URL shortener (lambda/s3) to generate a short, branded link pointing to the signed url. This is what we send to the user when they request a document. The user is interacting with an Alexa skill in this case, and the skill sends the link via SMS or email immediately upon request.

@kylekirkby
Copy link

@ryanhollander Thanks for the insight!

@octaneC8H18
Copy link

octaneC8H18 commented Mar 5, 2021

Proposals 1 & 5 implemented in aws-amplify/amplify-cli#1262

But Proposal 5 is still not working. I still can not implement multi tenancy with user roles as proposed in aws-amplify/amplify-cli#317.

I want only the admin to have access on the type and only within the tenant.

My query is

 @auth(
   rules: [
     { allow: groups, groups: ["admin"] }
     { allow: groups, groupsField: "tenantId", groupClaim: "tenantId" }
   ]
 )

It still generates

#if( !($isStaticGroupAuthorized == true || $isDynamicGroupAuthorized == true || $isOwnerAuthorized == true) )
    $util.unauthorized()
  #end

Or am I missing something. What have I done wrong? could someone please help me? :)

@mikeparisstuff @undefobj @edwardfoyle

@mir1198yusuf
Copy link

mir1198yusuf commented Mar 22, 2021

Can anyone help me in this ?
It is a pretty common use case, I am sure someone might have found solution for group-owner if-found match instead of whole array comparison criteria ..

#455

@siegerts siegerts added the feature-request New feature or request label Sep 3, 2021
@loganpowell
Copy link

loganpowell commented Feb 2, 2022

I believe something like this might help address some of the concerns brought up in other issues. Some users - including myself - are looking at graphql interfaces as a channel for discussing granting permissions to various table items based on those implementations' @auth rules. However, if we could have auth set by an enum, we could have control over the publicity of an item from within that item instead of by the table.

enum PermissionType  {
  PUBLIC @auth( rules: [
      { allow: public, operations: [ read ] },
      { allow: groups, groups: ["Admins", "Editors"] }
    ])
  PRIVATE @auth( rules: [
      { allow: groups, groups: ["Admins", "Editors"] }
   ])
}

type Resource @model {
  id: ID! @primaryKey
  status: PermissionType! @index(name: "Resources_by_Status", queryField: "resourcesByStatus")
  name: String!
}

type Node @model {
  id: ID! @primaryKey
  status: PermissionType! @index(name: "Nodes_by_Status", queryField: "nodesByStatus")
  resources: [Resource] @hasMany(indexName: "Resources_by_Node", fields: ["id"])
  edges: [Edge] @manyToMany(relationName: "EdgeNode")
}

type Edge @model {
  id: ID! @primaryKey
  nodes: [Node] @manyToMany(relationName: "EdgeNode")
}

This would allow us to toggle permissions on an item by simply updating a single item's field, rather than having to delete a private item, create a public one and reconnect them to their various connections...
Would love to hear your thoughts on an approach such as this

@kylekirkby
Copy link

@loganpowell - this looks ideal. We've currently had to write custom resolvers to check if the user is using the unauth role, which, if they are, we don't return private resources. If the user is logged in then the permissions for resources are entirely based on their group membership.

@loganpowell
Copy link

@kylekirkby do you have a GSI that has the resources indexed by auth role? Might you share your custom resolver?

@kylekirkby
Copy link

kylekirkby commented Feb 3, 2022

@kylekirkby do you have a GSI that has the resources indexed by auth role? Might you share your custom resolver?

Hi @loganpowell ,

So currently our Resource type has a private boolean field. This is then used to prevent users from seeing private resources in queries via custom VTL code. Currently, I've only managed to get this working in the response resolver, which means when you expect 12 resources in a query you get 12 - any that were private.

What I'm looking at now is updating the client-side queries to filter based on the private value so the correct number of resources are returned to unauth users. It would be much cleaner to get the req resolver filtering these based on the use of the unauth role but, as you probably know, documentation and guides are severely lacking for VTL + Amplify.

So in

amplify/backend/api/yourAPIName/resolvers/Query.resourcesByCreatedDate.res.vtl

I've got this:

#if( $ctx.identity.userArn == $ctx.stash.unauthRole )
  #set( $items = [] )
  #foreach( $item in $ctx.result.items )
    #set( $isPrivate = $util.defaultIfNull($item.private, false) )
    #if( $isPrivate == false )
      $util.qr($items.add($item))
    #end
  #end
  #set( $ctx.result.items = $items )
#end

#if( $ctx.error )
  $util.error($ctx.error.message, $ctx.error.type)
#end
$util.toJson($ctx.result)

If the user is using the unauth role, then private resources are not returned. You'll have to add this logic to all of your resource queries by overriding the amplify generated resolvers.

There may be bits I'm missing but I hope this helps! This has been one hell of an issue to solve for me!

Edit: this will only work on the v2 GraphQL transformer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests