Cas d'utilisation de l'autorisation - AWS AppSync

Cas d'utilisation de l'autorisation

Dans la section Sécurité, vous avez découvert les différents modes d'autorisation pour protéger votre API et avez bénéficié d'une présentation des mécanismes d'autorisation granulaire pour comprendre les concepts et le flux. Comme AWS AppSync vous permet d'exécuter des opérations logiques sur les données via l'utilisation des Modèles de mappage de résolveurs GraphQL, vous pouvez protéger les données en lecture ou en écriture de manière très flexible grâce à une combinaison d'identité utilisateur, de conditions et d'injection des données.

Si vous n'êtes pas familiarisé avec la modification des résolveurs AWS AppSync, passez en revue le guide de programmation.

Présentation

L'octroi de l'accès aux données d'un système est généralement effectué par le biais d'une matrice de contrôle d'accès où l'intersection d'une ligne (ressource) et d'une colonne (utilisateur/rôle) définit les autorisations accordées.

AWS AppSync utilise les ressources de votre propre compte et les informations sur l'identité des threads (utilisateur/rôle) dans la demande et la réponse GraphQL sous la forme d'un objet contexte que vous pouvez utiliser dans le résolveur. Cela signifie que les autorisations peuvent être accordées de façon appropriée sur des opérations de lecture ou d'écriture en fonction de la logique du résolveur. Si cette logique est au niveau de la ressource, par exemple, seuls certains utilisateurs ou groupes nommés peuvent lire/écrire dans une ligne de base de données spécifique, alors ces « métadonnées d'autorisation » doivent être enregistrées. AWS AppSync ne stocke aucune donnée, vous devez donc stocker ces métadonnées d'autorisation avec les ressources pour que les permissions puissent être calculées. Les métadonnées d'autorisation sont généralement un attribut (colonne) d'une table DynamoDB, tel qu'un propriétaire ou une liste d'utilisateurs/groupes. Par exemple, il pourrait y avoir des attributs Readers et Writers.

Depuis un niveau élevé, cela signifie que si vous lisez un élément individuel à partir d'une source de données, vous devez effectuer une déclaration conditionnelle #if () ... #end dans le modèle de réponse après que le résolveur a lu à partir de la source de données. Le contrôle utilisera normalement les valeurs d'utilisateur ou de groupe dans $context.identity pour les contrôles d'appartenance par rapport aux métadonnées d'autorisation renvoyées par une opération de lecture. Pour plusieurs enregistrements, tels que les listes renvoyées à partir d'une table Scan ou Query, vous envoyez le contrôle de la condition à la source de données, comme partie intégrante de l'opération, à l'aide de valeurs d'utilisateur ou de groupe similaires.

De même, lors de l'écriture des données, vous appliquerez une instruction conditionnelle à l'action (comme un PutItem ou UpdateItem pour voir si l'utilisateur ou le groupe effectuant la mutation a l'autorisation). L'instruction conditionnelle utilisera à nouveau à plusieurs reprises une valeur dans $context.identity à titre de comparaison avec les métadonnées d'autorisation sur cette ressource. Pour les modèles de demande et de réponse, vous pouvez également utiliser des en-têtes personnalisés provenant des clients pour effectuer les contrôles de validation.

Lecture de données

Comme indiqué ci-dessus, les métadonnées d'autorisation pour effectuer un contrôle doivent être stockées avec une ressource ou transmises à la demande GraphQL (identité, en-tête, etc.). Pour illustrer cela, supposons que vous ayez la table DynamoDB ci-dessous :

La clé primaire est id et les données qui doivent être accessibles Data. Les autres colonnes sont des exemples de vérifications que vous pouvez exécuter pour l'autorisation. Owner serait une String alors que PeopleCanAccess et GroupsCanAccess seraient des String Sets comme il est indiqué dans Référence du modèle de mappage des résolveurs pour DynamoDB.

Dans la présentation du modèle de mappage des résolveurs, le diagramme montre comment le modèle de réponse contient non seulement l'objet de contexte, mais aussi les résultats de la source de données. Pour les requêtes GraphQL des objets individuels, vous pouvez utiliser le modèle de réponse pour vérifier si l'utilisateur est autorisé à voir les résultats ou à renvoyer un message d'erreur relatif à l'autorisation. Cet élément est parfois appelé « filtre d'autorisation ». Pour les requêtes GraphQL renvoyant des listes, à l'aide d'une requête (Query) ou d'une analyse (Scan), il est plus performant d'effectuer le contrôle sur le modèle de demande et de ne renvoyer les données que si une condition d'autorisation est remplie. L'implémentation se présente alors ainsi :

  1. GetItem – Contrôle d'autorisation pour les enregistrements individuels. Fait à l'aide d'instructions #if() ... #end.

  2. Opérations Query/Scan – Le contrôle d'autorisation est une déclaration "filter":{"expression":...}. Les contrôles courants concernent l'égalité (attribute = :input) ou la vérification de la présence d'une valeur dans une liste (contains(attribute, :input)).

Dans #2, l'attribut attribute des deux déclarations représente le nom de colonne de l'enregistrement dans une table, comme Owner dans notre exemple ci-dessus. Vous pouvez créer un alias à l'aide du signe # et utiliser "expressionNames":{...}, mais ce n'est pas obligatoire. L'élément :input est une référence à la valeur que vous comparez à l'attribut de base de données, que vous définissez dans "expressionValues":{...}. Voyez les exemples ci-dessous.

Scénario : Le propriétaire peut lire

À l'aide du tableau ci-dessus, si vous souhaitez uniquement renvoyer les données si Owner == Nadia dans le cas d'une opération de lecture (GetItem), votre modèle se présente comme suit :

#if($context.result["Owner"] == $context.identity.username) $utils.toJson($context.result) #else $utils.unauthorized() #end

Quelques remarques à mentionner ici seront réutilisées dans les sections restantes. Tout d'abord, le contrôle utilise $context.identity.username qui sera le nom d'inscription convivial de l'utilisateur si les groupes d'utilisateurs Amazon Cognito sont utilisés et sera l'identité d'utilisateur si AWS IAM est utilisé (y compris les identités fédérées Amazon Cognito). Il existe d'autres valeurs à stocker pour un propriétaire comme la valeur unique « identité Amazon Cognito », ce qui est utile lorsque la fédération se connecte à partir de plusieurs emplacements, et vous devez passer en revue les options disponibles dans Référence du contexte du modèle de mappage des résolveurs.

Deuxièmement, le contrôle conditionnel « else » répondant avec $util.unauthorized() est totalement facultatif, mais recommandé comme bonne pratique lors de la conception de votre API GraphQL.

Scénario : Accès spécifique codé en dur

// This checks if the user is part of the Admin group and makes the call #foreach($group in $context.identity.claims.get("cognito:groups")) #if($group == "Admin") #set($inCognitoGroup = true) #end #end #if($inCognitoGroup) { "version" : "2017-02-28", "operation" : "UpdateItem", "key" : { "id" : $util.dynamodb.toDynamoDBJson($ctx.args.id) }, "attributeValues" : { "owner" : $util.dynamodb.toDynamoDBJson($context.identity.username) #foreach( $entry in $context.arguments.entrySet() ) ,"${entry.key}" : $util.dynamodb.toDynamoDBJson($entry.value) #end } } #else $utils.unauthorized() #end

Scénario : Filtrage d'une liste de résultats

Dans l'exemple précédent, vous avez été en mesure de contrôler directement $context.result tandis qu'il renvoyait un seul élément ; cependant, certaines opérations telles qu'une analyse renvoient plusieurs éléments dans $context.result.items où vous devez exécuter le filtre d'autorisation de filtre et retourner uniquement les résultats que l'utilisateur est autorisé à afficher. Supposons que le champ Owner ait cette fois l'IdentityID Amazon Cognito défini sur l'enregistrement, vous pouvez ensuite utiliser le modèle de mappage de réponse suivant pour filtrer uniquement les enregistrements que l'utilisateur possède :

#set($myResults = []) #foreach($item in $context.result.items) ##For userpools use $context.identity.username instead #if($item.Owner == $context.identity.cognitoIdentityId) #set($added = $myResults.add($item)) #end #end $utils.toJson($myResults)

Scénario : Plusieurs personnes peuvent lire

Une autre option d'autorisation populaire consiste à autoriser un groupe de personnes à pouvoir lire les données. Dans l'exemple ci-dessous, "filter":{"expression":...} renvoie uniquement les valeurs d'une analyse de table si l'utilisateur exécutant la requête GraphQL est répertorié dans l'ensemble PeopleCanAccess.

{ "version" : "2017-02-28", "operation" : "Scan", "limit": #if(${context.arguments.count}) $util.toJson($context.arguments.count) #else 20 #end, "nextToken": #if(${context.arguments.nextToken}) $util.toJson($context.arguments.nextToken) #else null #end, "filter":{ "expression": "contains(#peopleCanAccess, :value)", "expressionNames": { "#peopleCanAccess": "peopleCanAccess" }, "expressionValues": { ":value": $util.dynamodb.toDynamoDBJson($context.identity.username) } } }

Scénario : Le groupe peut lire

Comme pour le dernier scénario, il se peut que seules les personnes d'un ou de plusieurs groupes aient les droits pour lire certains éléments d'une base de données. L'utilisation de l'opération "expression": "contains()" est similaire ; cependant, le fait qu'un utilisateur puisse faire partie de ce qui doit être pris en compte dans l'appartenance à l'ensemble relève d'un OR logique de tous les groupes. Dans ce cas, nous créons une instruction $expression ci-dessous pour chaque groupe dont fait partie l'utilisateur, puis la transmettons au filtre :

#set($expression = "") #set($expressionValues = {}) #foreach($group in $context.identity.claims.get("cognito:groups")) #set( $expression = "${expression} contains(groupsCanAccess, :var$foreach.count )" ) #set( $val = {}) #set( $test = $val.put("S", $group)) #set( $values = $expressionValues.put(":var$foreach.count", $val)) #if ( $foreach.hasNext ) #set( $expression = "${expression} OR" ) #end #end { "version" : "2017-02-28", "operation" : "Scan", "limit": #if(${context.arguments.count}) $util.toJson($context.arguments.count) #else 20 #end, "nextToken": #if(${context.arguments.nextToken}) $util.toJson($context.arguments.nextToken) #else null #end, "filter":{ "expression": "$expression", "expressionValues": $utils.toJson($expressionValues) } }

Écriture de données

L'écriture de données sur les mutations est toujours contrôlée sur le modèle de mappage de la demande. Dans le cas des sources de données DynamoDB, la clé consiste à utiliser un "condition":{"expression"...}" approprié, qui effectue la validation par rapport aux métadonnées d'autorisation de la table. Dans Sécurité, nous avons fourni un exemple que vous pouvez utiliser pour vérifier le champ Author dans une table. Les cas d'utilisation de cette section explorent d'autres scénarios.

Scénario : Plusieurs propriétaires

À l'aide de l'exemple de schéma de table précédent, imaginons la liste PeopleCanAccess

{ "version" : "2017-02-28", "operation" : "UpdateItem", "key" : { "id" : $util.dynamodb.toDynamoDBJson($ctx.args.id) }, "update" : { "expression" : "SET meta = :meta", "expressionValues": { ":meta" : $util.dynamodb.toDynamoDBJson($ctx.args.meta) } }, "condition" : { "expression" : "contains(Owner,:expectedOwner)", "expressionValues" : { ":expectedOwner" : $util.dynamodb.toDynamoDBJson($context.identity.username) } } }

Scénario : Le groupe peut créer un nouvel enregistrement

#set($expression = "") #set($expressionValues = {}) #foreach($group in $context.identity.claims.get("cognito:groups")) #set( $expression = "${expression} contains(groupsCanAccess, :var$foreach.count )" ) #set( $val = {}) #set( $test = $val.put("S", $group)) #set( $values = $expressionValues.put(":var$foreach.count", $val)) #if ( $foreach.hasNext ) #set( $expression = "${expression} OR" ) #end #end { "version" : "2017-02-28", "operation" : "PutItem", "key" : { ## If your table's hash key is not named 'id', update it here. ** "id" : $util.dynamodb.toDynamoDBJson($ctx.args.id) ## If your table has a sort key, add it as an item here. ** }, "attributeValues" : { ## Add an item for each field you would like to store to Amazon DynamoDB. ** "title" : $util.dynamodb.toDynamoDBJson($ctx.args.title), "content": $util.dynamodb.toDynamoDBJson($ctx.args.content), "owner": $util.dynamodb.toDynamoDBJson($context.identity.username) }, "condition" : { "expression": $util.toJson("attribute_not_exists(id) AND $expression"), "expressionValues": $utils.toJson($expressionValues) } }

Scénario : Le groupe peut mettre à jour un enregistrement existant

#set($expression = "") #set($expressionValues = {}) #foreach($group in $context.identity.claims.get("cognito:groups")) #set( $expression = "${expression} contains(groupsCanAccess, :var$foreach.count )" ) #set( $val = {}) #set( $test = $val.put("S", $group)) #set( $values = $expressionValues.put(":var$foreach.count", $val)) #if ( $foreach.hasNext ) #set( $expression = "${expression} OR" ) #end #end { "version" : "2017-02-28", "operation" : "UpdateItem", "key" : { "id" : $util.dynamodb.toDynamoDBJson($ctx.args.id) }, "update":{ "expression" : "SET title = :title, content = :content", "expressionValues": { ":title" : $util.dynamodb.toDynamoDBJson($ctx.args.title), ":content" : $util.dynamodb.toDynamoDBJson($ctx.args.content) } }, "condition" : { "expression": $util.toJson($expression), "expressionValues": $utils.toJson($expressionValues) } }

Enregistrements publics et privés

Avec les filtres conditionnels, vous pouvez également choisir de marquer les données comme privées, publiques ou booléennes. Elles peuvent ensuite être combinées dans le cadre d'un filtre d'autorisation à l'intérieur du modèle de réponse. L'utilisation de ce contrôle est un bon moyen de masquer les données temporairement sans tenter de contrôler l'appartenance au groupe.

Par exemple, imaginons que vous ayez ajouté un attribut sur chaque élément de votre table DynamoDB appelé public avec la valeur yes ou no. Le modèle de réponse suivant peut être utilisé sur un appel GetItem pour afficher les données uniquement si l'utilisateur se trouve dans un groupe qui a accès ET si ces données sont marquées comme publiques :

#set($permissions = $context.result.GroupsCanAccess) #set($claimPermissions = $context.identity.claims.get("cognito:groups")) #foreach($per in $permissions) #foreach($cgroups in $claimPermissions) #if($cgroups == $per) #set($hasPermission = true) #end #end #end #if($hasPermission && $context.result.public == 'yes') $utils.toJson($context.result) #else $utils.unauthorized() #end

Le code ci-dessus peut également utiliser un OR logique (||) pour autoriser les personnes à lire si elles sont autorisés à accéder à un enregistrement ou s'il est public :

#if($hasPermission || $context.result.public == 'yes') $utils.toJson($context.result) #else $utils.unauthorized() #end

En général, vous trouverez les opérateurs standard ==, !=, && et || utiles lors de l'exécution des contrôles d'autorisation.

Données en temps réel

Vous pouvez appliquer les contrôles d'accès détaillé aux abonnements GraphQL au moment où un client effectue un abonnement, en utilisant les mêmes techniques que celles décrites plus haut dans la documentation. Vous attachez un résolveur au champ d'abonnement et pouvez alors interroger les données à partir d'une source de données et exécuter une logique conditionnelle dans le modèle de mappage de la demande ou de la réponse. Vous pouvez également renvoyer des données supplémentaires au client, telles que les résultats initiaux d'un abonnement, aussi longtemps que la structure des données correspond à celle du type retourné dans votre abonnement GraphQL.

Scénario : L'utilisateur ne peut s'abonner qu'à des conversations spécifiques

Un cas d'utilisation courant pour les données en temps réel avec les abonnements GraphQL consiste à créer une application de messagerie ou chat privé. Lors de la création d'une application de chat qui comporte plusieurs utilisateurs, les conversations peuvent se produire entre deux ou plusieurs personnes. Celles-ci peuvent être regroupées en « salles », qui sont privées ou publiques. À ce titre, vous souhaitez uniquement autoriser un utilisateur à s'abonner à une conversation (qui pourrait être en face à face ou au sein d'un groupe) pour laquelle l'accès lui a été accordé. À des fins de démonstration, l'exemple ci-dessous illustre un simple scénario d'un utilisateur envoyant un message privé à un autre utilisateur. La configuration possède deux tables Amazon DynamoDB :

  • Table Messages : (clé primaire) toUser, (clé de tri) id

  • Table Permissions : (clé primaire) username

La table Messages stocke les messages réels envoyés via une mutation GraphQL. La table Permissions est contrôlée par l'abonnement GraphQL pour l'autorisation au moment de la connexion du client. L'exemple suivant suppose que vous utilisez le schéma GraphQL suivant :

input CreateUserPermissionsInput { user: String! isAuthorizedForSubscriptions: Boolean } type Message { id: ID toUser: String fromUser: String content: String } type MessageConnection { items: [Message] nextToken: String } type Mutation { sendMessage(toUser: String!, content: String!): Message createUserPermissions(input: CreateUserPermissionsInput!): UserPermissions updateUserPermissions(input: UpdateUserPermissionInput!): UserPermissions } type Query { getMyMessages(first: Int, after: String): MessageConnection getUserPermissions(user: String!): UserPermissions } type Subscription { newMessage(toUser: String!): Message @aws_subscribe(mutations: ["sendMessage"]) } input UpdateUserPermissionInput { user: String! isAuthorizedForSubscriptions: Boolean } type UserPermissions { user: String isAuthorizedForSubscriptions: Boolean } schema { query: Query mutation: Mutation subscription: Subscription }

Certaines opérations standard, telles que createUserPermissions(), ne sont pas couvertes ci-dessous pour illustrer les résolveurs d'abonnement, mais sont des implémentations standard des résolveurs DynamoDB. Au lieu de cela, nous allons nous concentrer sur les flux d'autorisation d'abonnement avec les résolveurs. Pour envoyer un message d'un utilisateur à un autre, attachez un résolveur au champ sendMessage() et sélectionnez la source de données de la table Messages avec le modèle de demande suivant :

{ "version" : "2017-02-28", "operation" : "PutItem", "key" : { "toUser" : $util.dynamodb.toDynamoDBJson($ctx.args.toUser), "id" : $util.dynamodb.toDynamoDBJson($util.autoId()) }, "attributeValues" : { "fromUser" : $util.dynamodb.toDynamoDBJson($context.identity.username), "content" : $util.dynamodb.toDynamoDBJson($ctx.args.content), } }

Dans cet exemple, nous utilisons $context.identity.username. Cela renvoie les informations utilisateur pour les utilisateurs AWS Identity and Access Management ou Amazon Cognito. Le modèle de réponse est une simple transmission de $util.toJson($ctx.result). Enregistrez et revenez à la page du schéma. Ensuite, attachez un résolveur pour l'abonnement newMessage(), à l'aide de la table Permissions comme source de données et du modèle de mappage de demande suivant :

{ "version": "2018-05-29", "operation": "GetItem", "key": { "username": $util.dynamodb.toDynamoDBJson($ctx.identity.username), }, }

Ensuite, utilisez le modèle de mappage de réponse suivant pour exécuter vos contrôles d'autorisation à l'aide des données de la table Permissions :

#if(! ${context.result}) $utils.unauthorized() #elseif(${context.identity.username} != ${context.arguments.toUser}) $utils.unauthorized() #elseif(! ${context.result.isAuthorizedForSubscriptions}) $utils.unauthorized() #else ##User is authorized, but we return null to continue null #end

Dans ce cas, vous effectuez trois contrôles d'autorisation. Le premier garantit qu'un résultat est retourné. Le second garantit que l'utilisateur ne s'abonne pas aux messages destinés à une autre personne. Le troisième garantit que l'utilisateur est autorisé à s'abonner à tous les champs, en vérifiant un attribut DynamoDB isAuthorizedForSubscriptions stocké en tant que BOOL.

À des fins de test, vous pouvez vous connecter à la console AWS AppSync à l'aide des groupes d'utilisateurs Amazon Cognito et d'un utilisateur nommé « Nadia », puis exécuter l'abonnement GraphQL suivant :

subscription AuthorizedSubscription { newMessage(toUser: "Nadia") { id toUser fromUser content } }

Si, dans la table Permissions, il y a un enregistrement pour l'attribut clé username de Nadia avec isAuthorizedForSubscriptions défini sur true, vous obtiendrez une réponse positive. Si vous essayez un autre username dans la requête newMessage() ci-dessus, une erreur est renvoyée.