Authorisation & Scopes

I am going to implement authorisation within a Hanami project and I am looking how best to implement scoping with the Repository / Entity model, and instead of re-inventing the wheel I was hoping someone else had done so successfully. e.g. not passing the relation to the authorisation layer and chaining expressions where(admin: true)

My current thinking is to get the authorisation layer return a Proc, that can be passed into the repository method as a scope argument

e.g.

scope = proc { |r| r.where(admin: true) }

user_repo.some_method(scope: scope)

thoughts?

This will be a plugin in rom 6.0 but for now it’s just a matter of overriding root method and using super, ie:

def root
  super.where(admin: true)
end

As you can probably imagine, this can be encapsulated by some macro, like scope :admin that would define root automatically. It’s also nice to have such scopes defined as relation views for better encapsulation. This can feel like an overkill but only in the beginning :slightly_smiling_face:

@solnic for clarification: the idea is to have separate repos for different levels of authorization? Since overriding root is only possible on the repo level I suppose.

@solnic Thank you for your reply.

I am currently injecting the repositories as a dependency in my controllers e.g.

include Import[entity_repo: 'repositories.entities.entity']

So I suppose the only way to achieve this would to either take the dependency out of the import, and as @apohllo suggests have a separate instance per level of authorisation, although this does pose a problem when the scope has a dynamic part to it e.g. user_id as we would have to pass this in as part of the constructor, and I would also lose all the benefit of reduced object allocation by freezing and memoizing the repository dependency which is what I am currently doing at the moment.

Well, generally I think that the reason for introducing repositories is the fact that you don’t want any details of the data layer leaking into the controllers. Passing the proc from controller to the repo, I believe, is a direct violation of this rule. There are the following options I suppose:

  • have different repos, for different levels of authorization, e.g. admin API would use AdminRepos (this works well with memoization - you just need to define an AdminRepo which inherits from the “default” repo and changes the root as @solnic suggests; yet it doesn’t work with the user_id)
  • make the user id/authorization level/whatever a part of the API of the repository - similar to your solution, but the difference here is that you don’t pass any db-layer details from the authorization layer. E.g. instead of passing the scope, you pass user_id or user role or some other type of requirement. How it is interpreted is essentially the responsibility of the db-layer
  • use dry-effect/reader - say with_scope which would be consumed by the repo. You can pass the scope, but also you can pass more model-oriented data, like the user_id, user roles etc.

To elaborate a bit on the “AdminRepo” - I mean you would need an AdminRepo class, eg. AdminNewsRepo or Admin::NewsRepo for each regular repo, eg. NewsRepo. This plays well only if you have two levels of authorization, it does not play well with the user_id.

Using multiple repo classes for the same struct type for this purposes is indeed a solution. I did exactly this and typically I’d have ie Public::StuffRepo and Admin::StuffRepo with admin-specific logic.

If there’s some “context” from the outside that’s needed to perform a query, then I 100% encourage you to try out dry-effects like @apohllo suggested. It’s very easy to add it to an existing code base and it simplifies many things.

Essentially, I just repeated what Aleksander said :smiley:

I actually started down the route of dry-effect/reader, although there is a gem dependency which prevents it being used with Hanami which if memory serves me correctly was https://dry-rb.org/gems/dry-initializer/3.0/. I may try forking the repo and see if I can downgrade the dependency to use the same version I am currently locked to.

@solnic / @apohllo thank you for your thoughts, I am going hopefully work on this next week and I will report back with what I decide.

@DangerDawson lemme know if you need help with dependencies/versions. We may need to adjust some gemspecs maybe, so don’t hesitate to report issues.

@solnic I have managed to get dry-effects working with hanami by doing the following:

Bumping the dependency in the rom 3.3.3. gemspec to:

gem.add_runtime_dependency "dry-initializer", "~> 3.0"

disabling the following check: dry-initializer/build_nested_type.rb at master · dry-rb/dry-initializer · GitHub

 return unless name[/^_|__|_$/]

Which was being thrown by the following in rom-repository: https://github.com/rom-rb/rom-repository/blob/v1.4.0/lib/rom/repository/changeset/stateful.rb#L16
(I have a limit of 2 links per post, hence the disabled hyperlink)

option :__data__, optional: true

Updating the following: rom/initializer.rb at v3.3.0 · rom-rb/rom · GitHub

from:

 def options
    @__options__
 end

to

 def options
  self.class.dry_initializer.attributes(self)
 end

Which is a big hack, any maybe not correct, but it got it working.

What are your thoughts on the best way to implement this? shall I just fork rom and update the gemspec, then monkey patch it within my hanami project?

Oh thanks for digging into this!

@flash-gordon have we missed some improvement in dry-initializer and forgot to update ROM::Initializer accordingly? Seems like we have :slightly_smiling_face:Maybe we should fix it and bump dry-initializer in current 5.x line?

@solnic I believe it would be a case of doing a search and replace for __data__ which is not allowed as a option in the latest version of dry-initializer e.g. I renamed it to private_data

BTW. this breaking change has just arrived in the latest version of dry-initializer

Also the following can be simplified:

with

self.class.dry_initializer.attributes(self)

?

I don’t have time to look in details but it wouldn’t work, we updated code with lazy assignment in InstanceMethods.

self.class.dry_initializer.attributes(self)

It would be slower than current version since it doesn’t cache.

1 Like

Sorry I meant:

@__options__ ||= self.class.dry_initializer.attributes(self)

Although if:

@__options__ ||= self.class.dry_initializer.definitions.values.each_with_object({}) do |item, obj|
  obj[item.target] = instance_variable_get(item.ivar)
end

Gives a different behaviour I will fork rom v3.3.0 and use that instead of what I proposed

oh boy I totally missed the fact that we’re talking about rom 3.3 :confused: