ABAC replacement for public/unauthenticated client access?

Hi. I see that client keys and roles and the public permission were deprecated in 2.12.0 in favor of ABAC. I see in the docs how to use ABAC for authenticated users, but what’s the best practice for unauthenticated?

I have two scenarios. In one, most like the client role access, unauthenticated users only have read access. Perhaps I just create a single user with an empty password to represent all unauthenticated users and use that for ABAC? And then make it impossible to sign into that user normally?

In the other scenario, unauthenticated users can create content and then I want them to be able to create an account and claim their content. If they never create an account then I can clean up unclaimed content, as well as their unclaimed unauthenticated user records, out in background jobs. Perhaps I create multiple users for unauthenticated users with empty passwords in that case, and “creating an account” means adding a real user name and password to the formerly-anonymous user record?

If I missed docs about this, apologies: please send them over. :slight_smile:

Thanks,
Gary

1 Like

Hi Gary,

In the most simple scenario you would create a bootstrap role that has two permissions:

  • Permissions to log in. This could be access to an user_by_email index or just the right to call a User Defined Function or UDF that logs your user in.
  • Permissions to register. This could be access to create a new user or again a UDF

Note: I prefer UDFs in such case, since it’s a convenient way to bundle some logic together such as searching a user by e-mail, calling login etc.

For example, your bootstrap role could look like this (non UDF approach):

{
  name: 'keyrole_register_login_verify_token',
  privileges: [
    {
      resource: Collection('accounts'),
      // accounts can be created.
      // accounts can be updated to change the verification
      actions: {
        create: true,
        read: true
      }
    },
    {
      resource: Index('accounts_by_email'),
      actions: { read: true }
    }
  ]
}

For which you then can create a key (with the CreateKey function) to bootstrap your app. You do not need to create a fake user and login with that, if you do not need to have an Identity (see the Identity) function) to be attached to the secret than you don’t need that. If that initial access is however not anonymous you can make a Token as well (Keys are anonymous, Tokens have an identity or database document attached to them) by using

Create(Tokens(), {
        instance: <reference to some database entity> 
}

This gives you a similar token as Login would give you, only without the credentials requirement.
Tokens are documented (and creation will soon be) documented here
Keys documentation can be found here.

Your scenario

The above assumes no read access to your resources accept what is necessary to login. For your first scenario:

  • add read access to the resources you want to make publicly available

Second scenario:

  • You are on the right track I think to have passwordless users there. You can use the passwordless token approach above for that (maybe via an UDF) and once the user is claimed, update the document to add credentials and revert to Login to generate your tokens.

Example

There is an example here (with Github repo) that creates ‘client roles’ as well as ‘logged in’ roles.

4 Likes

That’s fantastic help @databrecht. Thank you. I see the appeal of UDFs for the permissions. And I had seen the existence of the Fwitter article but hadn’t actually clicked on it before: yes, that’s a great resource.

I do have one follow-on.

For the second scenario, in which I want both authenticated and unauthenticated users to be able to create owned content that can then follow the user through claiming an account, that requires an Identity function with an actual empty password, right? Or else I’d have to store and handle user identity differently depending on whether the user was authenticated?

This feels like the same story for building an app that’s an SSO consumer–using federated identity from another source. Wouldn’t that need to use Identity but with an empty password also?

Yes, absolutely, SSO would need the same. You would create a token in the backend based on another token so there is no password checking. Your backend just decides that the token from the external party is valid and exchanges it for a Fauna token. And the main reason you do that is that this external token doesn’t know about Fauna’s role system so you have to exchange it with a FaunaDB token to actually use the Identity() function and be able to use our ABAC system. That backend of course needs the permissions to create a token.

But I don’t get why you mention empty password. Think of it differently, it’s just a token that is created using the

Create(Tokens(), {
        instance: <reference to some database entity> 
}

syntax. And that is, of course, done by a part of your application (backend) that has a Key that allows you to create tokens (You can add ‘Tokens()’ as a resource to a role instead of a collection via FQL, I don’t think the dashboard allows that).

While Login is actually a combination of

  • Identify() -> check whether the password is correct
  • Create(Tokens()… -> make the token if it was correct.

You can perfectly implement Login yourself with these. What I’m saying is, although we offered Login at first and added Create(Tokens()… ) later. Login is actually the special case that checks a password, which allows you to create tokens without requiring a backend that has permissions to creat etokens. For the rest, it’s just all about creating tokens :).

4 Likes

Oh! I get it! Perfect, thank you. :slight_smile:

Hello! To follow up, is it correct to infer that omitting a membership field makes the role “public”?

Hi @tmikeschu. They do not become public, but the opposite: no documents in the DB are members.

But the role may still be useful. Functions have a role field which, if provided, the function will use that role instead of the role(s) of the calling client. In this way, you can have a “user” role with only the permission to call some function. Then such function could be assigned a role that grants the function access to the collections, indexes, other functions, etc. that it needs to work. In fact, you will see this technique used extensively in Brecht’s examples.

To use a role as public one, you would create the role with no membership. Then create a key with that role. That key needs to be made publicly available to client applications – then it will be “public”.

Thanks for the reply, @ptpaterson!

To ensure I understand:

Assigning a role to UDFs can encapsulate logic more semantically, and “people” roles like user would have access to UDFs as opposed to direct collections, indexes, etc?

A role without a membership field can be used to create a key for the use case of an unauthenticated viewing a page that might display read-only data or allow a mutation to create an account.

Thanks again!

2 Likes

Well put @tmikeschu!

1 Like

How this will work for GraphQL with this schema?

type User {
   email: String! @unique
   todos: [Todo] @relation
}

type Todo {
   title: String!
   completed: Boolean!
   user: User!
}

After uploading this schema, Fauna creates two indexes:

todo_user_by_user

{
  name: "todo_user_by_user",
  unique: false,
  serialized: true,
  source: "Todo",
  terms: [
    {
      field: ["data", "user"]
    }
  ]
}

and unique_User_email

{
  name: "unique_User_email",
  unique: true,
  serialized: true,
  source: "User",
  terms: [
    {
      field: ["data", "email"]
    }
  ]
}

1/ I have tried to use your example by creating the role:

{
  ref: Role("keyrole_todo_by_user"),
  ts: 1604651809160000,
  name: "keyrole_todo_by_user",
  privileges: [
    {
      resource: Collection("Todo"),
      actions: {
        create: true,
        read: true
      }
    },
    {
      resource: Index("todo_user_by_user"),
      actions: {
        read: true
      }
    }
  ],
  membership: []
}

2/ then issue a toke for a user

Create(Tokens(), { instance: Ref(Collection("User"), "281362746916733445") })

{
  ref: Ref(Ref("tokens"), "281431247046050311"),
  ts: 1604652602210000,
  instance: Ref(Collection("User"), "281362746916733445"),
  secret: "xxx"
}

3/ Then use the secret as the authorization header in the playground

{
  "authorization": "Basic xxx"
}

It returns the error message Invalid authorization header for any query like findUserByID or findTodoByID

I’m not sure what is wrong with this flow

In case it helps, an image that shows all the usages of roles.
image

Which soon will need to be updated with the upcoming AccessProvider functionality. Hence why the blog that uses this image is not out yet :slight_smile:

1 Like

Could you try with Bearer? Iirc it should be Bearer instead of Basic.

1 Like

Yes, it works with Bearer

I have read this thread multiple times and investigated Fwitter code extensively. Still, I can’t understand why I keep getting Insufficient privileges to perform the action.

The public role:

{
  ref: Role("anonymous"),
  name: "anonymous",
  privileges: [
    {
      resource: Collection("GhostAccount"),
      actions: {
        create: true,
        read: true
      }
    },
    {
      resource: Ref(Ref("functions"), "createGhostAccount"),
      actions: {
        call: true
      }
    }
  ],
  membership: []
}

The UDF:

Query(
  Lambda(
    ['fingerprint'],
    Let(
      {
        account: Select(
          ['ref'],
          Create(Collection('GhostAccount'), {
            data: { fingerprint: Var('fingerprint') }
          })
        )
      },
      Create(Tokens(), { instance: Var('account') })
    ),
  )
)

Then I created a key in the dashboard with the role anonymous and tried running the function, as below:

echo 'Call(Function("createGhostAccount"), 123)' | fauna shell --secret=<anonymous-associated-secret>

I always get ‘permission denied’ unless I replace the secret by an admin/server one.