AshAuthentication with multi-tenancy and API keys strategy

Just getting into multi-tenancy and the Ash support for it. My schema looks like:

Organisation (global, tenant entity)
Identity (global) —(has many)—> User (tenant)
User (tenant) —(has many)—> Project (tenant)
Token (global)

So if a User has a record, it means the Identity belongs to the same Organisation as the User record. Authentication is performed for Identity.

Is this reasonable so far?

Now I want to add the API key strategy. But the assumptions are based on a uni-tenant configuration, it seems, adding another strategy to what my schema calls Identity now. But I need the API keys to be associated to an organisation as well, so they need to work for User.

Can I just enable extensions: [AshAuthentication] for User? Of course I tried, but Ash complains:

    ** (Spark.Error.DslError) authentication -> tokens -> enabled?:
  The `:api_key` authentication strategy requires tokens be enabled.

But “tokens” means JWT tokens, no?

What is the recommended approach here? Or is there a flaw in my foundation?

Thanks again to the Ash team for the amazing work! :face_blowing_a_kiss:

2 Likes

Tokens are not necessarily JWTs. It is the token resource that we use to store api keys.

Actually, let me double check myself on that front.

Yeah, I’m just misremembering the implementation. We should not be requiring that. Fixed here: fix: don't require token resource for API keys · team-alembic/ash_authentication@b95b22e · GitHub

1 Like

Ok, tried with latest version. Now it says for User:

** (EXIT from #PID<0.94.0>) an exception was raised:
    ** (Spark.Error.DslError) authentication -> session_identifier:
  Must set `authentication.session_identifier` to either `:jti` or `:unsafe`,
unless `authentication.tokens.require_token_presence_for_authentication?` is set to `true`.

If you are seeing this error while upgrading ash_authentication, be aware that
updating this setting will log out all of your users.

When set to `:unsafe`, tokens are not revoked when the user logs out.
When set to `:jti`, we use this information to revoke tokens on logout.

We suggest setting `authentication.tokens.require_token_presence_for_authentication?` to `true`
to ensure that tokens are always present during authentication, which makes this option unnecessary.
Changing either of these settings will log out all of your users.

So I added

    tokens do
      require_token_presence_for_authentication? true
    end

(full user.ex)

leading to:

authentication -> tokens:
  ** (Spark.Options.ValidationError) required :token_resource option not found, received options: [:require_token_presence_for_authentication?]

It seems, it does rely on “tokens” somehow… :thinking:

Looks like just another check I need to fix :smiley:

You can try main again.

Yay, was able to create a token!

Thank you, Zach!

Found a GraphQL error message that might deserve proper handling. Just FYI, optional cosmetics for now… :wink:

{
  "data": {
    "createProject": {
      "errors": [
        {
          "message": "something went wrong. Unique error id: `892f6ed9-fce4-42e4-8f0c-827ffcf46b19`"
        }
      ],
      "result": null
    }
  }
}
[warning] `892f6ed9-fce4-42e4-8f0c-827ffcf46b19`: AshGraphql.Error not implemented for error:

** (Ash.Error.Invalid.TenantRequired) Queries against the Zeitmeister.TimeTracking.Project resource require a tenant to be specified
    (ash 3.5.24) lib/ash/error/invalid/tenant_required.ex:4: Ash.Error.Invalid.TenantRequired.exception/1
    (ash 3.5.24) lib/ash/actions/create/create.ex:602: Ash.Actions.Create.set_tenant/1

Just need to fix my scoping plug now, I guess…

1 Like

Hmm…that one is interesting. So the reason we did that is that externally the term for a tenant might be something other than “tenant”, so it would make sense to have you implement that yourself. There is a guide on doing that IIRC.

Ok, I introduced some denormalisation to avoid walking the tenant tables:

Organisation (global, tenant entity) —(has many)—> ApiKey (global)
User (tenant) —(has many)—> ApiKey (global)

I want to authenticate User (tenant) via ApiKey (global):

defmodule User do
  actions do
    read :sign_in_with_api_key do
      argument :api_key, :string, allow_nil?: false
      prepare AshAuthentication.Strategy.ApiKey.SignInPreparation
    end
    # [...]
  end
  # [...]
end

User is only accessible via tenant. Tenant can be derived directly from ApiKey.

AshAuthentication says:

22:49:59.723 request_id=GE7Y6Ni6AJHyb58AAABE [warning] Authentication failed:
Bread Crumbs:
  > Error returned from: Timetracker.Accounts.User.sign_in_with_api_key

Invalid Error

* Queries against the Timetracker.Accounts.User resource require a tenant to be specified
  (ash 3.5.24) lib/ash/error/invalid/tenant_required.ex:4: Ash.Error.Invalid.TenantRequired.exception/1
  (ash 3.5.24) lib/ash/actions/read/read.ex:2564: Ash.Actions.Read.validate_multitenancy/1
  (ash 3.5.24) lib/ash/actions/read/read.ex:2433: Ash.Actions.Read.handle_multitenancy/1
  (ash 3.5.24) lib/ash/actions/read/read.ex:474: Ash.Actions.Read.do_read/5
  (ash 3.5.24) lib/ash/actions/read/read.ex:330: Ash.Actions.Read.do_run/3
  (ash 3.5.24) lib/ash/actions/read/read.ex:89: anonymous fn/3 in Ash.Actions.Read.run/3
  (ash 3.5.24) lib/ash/actions/read/read.ex:88: Ash.Actions.Read.run/3
  (ash 3.5.24) lib/ash.ex:2760: Ash.read/2
  (ash_authentication 4.9.5) lib/ash_authentication/strategies/api_key/actions.ex:26: AshAuthentication.Strategy.ApiKey.Actions.sign_in/3
  (ash_authentication 4.9.5) lib/ash_authentication/strategies/api_key/plug.ex:116: AshAuthentication.Strategy.ApiKey.Plug.call/2

Which is a valid complaint…

What’s a good way to set the tenant? Maybe I could sneak in another before_action to validate (or at least identify) the API key and fetch Organisation via ApiKey, set the context, and let AshAuth validate it again. But that would be double work and I’m not sure it would even work, it seems, AshAuth (or tenant read actions in general) bails out before it even runs (which makes sense).

Copying AshAuthentication.Strategy.ApiKey.SignInPreparation and patching it to do that is not an option either, as AshAuth becomes mutinous unless exactly that preparation is present. And again, wouldn’t run anyway.

Ideas welcome. :slightly_smiling_face:

Ah, interesting. I think what you can do is allow bypassing multi tenancy for that read action, since every api key will uniquely be uniquely identified.

    read :sign_in_with_api_key do
      argument :api_key, :string, allow_nil?: false
      prepare AshAuthentication.Strategy.ApiKey.SignInPreparation
      multitenancy :bypass
    end

Hmm, as long as User is not global, the multitenancy :bypass does not help, right?

My idea was to keep it like that but tell Ash (or make it figure out) the correct tenant before trying to read the actual user record in the end.

Anyway, for now I completely removed multitenancy for User – should be good enough to get started. :slightly_smiling_face:

Right :thinking: yeah, we may need to add more consideration there, like encode the tenant into the api key in some way. Looks like a scenario I didn’t think through fully :cry:

Wouldn’t it be a way to extend AshAuthentication.Strategy.ApiKey.SignInPreparation to

  1. Check if the API key resource (here: ApiKey) belongs to the tenant entity resource (Organisation)
  2. If so, derive tenant from there and set the right tenant before reading the authenticated resource (User)?

Might play around some more tomorrow…

Ash doesn’t know about a “multitenancy resource”. It would likely require some kind of configuration/hook of some kind.