https://authzed.com logo
Title
m

Marcus Grass

01/12/2023, 9:33 AM
Lastly some detail, I'm looking at caveats. We have some contextual data that should not be stored to prevent data duplication/desync, caveats seem like a good way to provide that data, so I have some schema that I'm experimenting with that looks like this:
definition user {}

definition organization {
  relation member: user
  relation admin: user

  permission read_any_module = member + admin
  permission edit_any_module = admin
}

definition module {
  relation creator: user
  relation owner: organization
  relation sudo: user:* with escape_hatch

  permission read = sudo + creator + owner->read_any_module
  permission edit = sudo + creator + owner->edit_any_module
}
/** disregard using a string thats actually a bool when bool is a datatype, it's just messing around, ideally we'd be able to have a no-arg caveat here, like escape_hatch() but that doesn't parse right **/
caveat escape_hatch(open string) {
  open == "true"
}
What we'd like to achieve is that any user can access any resource if the context is provided, but since we can't wildcard resources we have to create a relationship with every module that specifically maps a wildcard user with escape_hatch to sudo. We don't have to do data-duplication so it's workable, but it isn't very economical in terms of data-storage. Is there a better way to achieve a similar setup?
v

vroldanbet

01/12/2023, 12:42 PM
Yeah this sounds like a kind of "staff-level access". Generally how we solve this is creating a top level definition called say "platform" or "root". Then every organization is linked to that platform, where staff members are assigned. We also plan to add time-bounded relationships, so you could grant permission for a period of time, but you can also do that today by adding a "valid until" field in the context.
`
definition user {}

definition platform {
  relation sudo: user with escape_hatch
}

definition organization {
  relation sudo: platform#sudo
  relation member: user
  relation admin: user

  permission read_any_module = member + admin
  permission edit_any_module = admin
}

definition module {
  relation platform: organization#platform
  relation creator: user
  relation owner: organization

  permission read = owner->sudo + creator + owner->read_any_module
  permission edit = owner->sudo + creator + owner->edit_any_module
}

caveat escape_hatch(open string) {
  open == "true"
}
hope that helps!
m

Marcus Grass

01/12/2023, 2:06 PM
I'm not 100% sure since I'm very new to the DSL here, 'sudo' was probably some bad naming because it draws the thoughts to admin. But what we would like to achieve is to create one relationship that enables us to use a caveat to get permission. I think what you linked, at least in my quick implementation, requires that a user gets a sudo relationship, and that that relationship is added to an organization that is also added to the module. But the flexibility that we'd like to check is if we could have a caveat that lets anyone get a certain set of permissions without adding a relationship for each user which should be able to use that caveat, or add that caveat relation to each resource. Expressed in the dsl, I'd like to be able to add this relationship:
module:* edit user:* with escape_hatch
only once. And what I think that expresses is, for any module, any user with the caveat
escape_hatch
is allowed to edit. Instead of what I tried
module:specific-module edit user:* with escape_hatch
which needs to be repeated for all modules. Ideally I wouldn't even need to persist that relationship at all, all the information necessary to make the decision is in the schema, it isn't even strictly a
relation
it's a special case of
permission
. Can you use caveats in permissions? I realize now that that is actually what I'm getting at here, being able to specify
permission edit = creator + owner->edit_any_module + user:* with escape_hatch
and not saving any relationship there at all. A practical case here is ownership, Ideally we wouldn't have that data, as a saved relation, but provide it at check-time, since the authorization-service doesn't store ownership data, that's persisted somewhere else. So the service that knows about ownership,
but not about the inner details of authorization sends a check like:
does user:<user> have permission:<edit> on module:<this-module> given that user:<user> is the creator
? And our caveat handles the case that a user that is the creator has a certain set of permissions, regardless of other relationship details
Just to add, your provided solution is excellent and 100% acceptable, I'm just wondering if there's a solution that requires fewer relationships, it might be a micro-optimization in this case
v

vroldanbet

01/12/2023, 2:23 PM
The concept of having the state of the world persisted in SpiceDB is fundamental to Zanzibar's architecture - that's how it can make the kind of optimizations and achieve the planetary scale it achieves. That's at odds with "this service does not own that data" -> data should be "denormalized" to SpiceDB/Zanzibar when it makes sense to compute an autorization decision. Using caveats here feels like workaround to now wanting to deal with state replication across service boundaries (which I get it - it's a complex problem, but there are solutions for it). What you want to achieve is not possible today without writing a single relation, unfortunately. You can tweak my proposal above to avoid having to write "every single user" in the
platform
object and instead would use
user:*
. That's one relation at the platform level, plus one relation per module to link it to the platform. The
module
already has an
owner
. At first glance that'd seem like the minimum amount of relationships you would need to achieve this, but maybe my colleagues have other ideas cc @Jake who likes to do these modelling exercises But being quite frank, you are basically opening a hole in the system: the client becomes in entire control of the result of the operation as long as they provide the right context. You may as well simply completely bypass SpiceDB in the client side at that point.
m

Marcus Grass

01/12/2023, 2:46 PM
I see, yeah your provided solution lowers the amount of relationships and is an acceptable solution, I'm just trying to figure out possibilities, in my experience, whichever solution you pick, one dark day you're going to need an escape_hatch. But speaking of punching holes, the authorization service doesn't make decisions on allow/reject, the "client" as you called it, does in this case, it just asks for "advice" and then does what it wishes with that advice. I don't see how that would reduce security but I might be missing something. No user is making this request and user input doesn't reach this request, a service which knows that a given user is a creator, will ask the authorization service if that user can perform some action, and then accepts or rejects the user's request depending on the response. To show more of what I'm talking about, some pseudocode from a theoretical module service:
fn edit_endpoint(user, module) {
  let module_metadata = module_db.get_metadata(module);
  let has_permission = authorization_service.has_permission(user, "edit", module, {"is_creator": module_metadata.creator == user});
  if has_permission {
    module_db.upsert(module);
  } else {
    return Http.403;
  }
}
It doesn't really matter if we can bypass authorization by providing context, since the module service could just not care about the response if it wishes, or just not ask, the authorization_service is a tool that the module service can use how it wishes, the burden of security falls on that implementation.
v

vroldanbet

01/12/2023, 3:11 PM
yep, escape hatches are definitely necessary 💯 I've seen them implemented in various ways, and ultimately your organization, compliance and needs will define what it would look like. SpiceDB gives you the primitives, the engineer ultimately decides how to model the business domain in it. And yes, you are also right that the authorization service can be ignored at any time - deliberately, or accidentally. I guess I was more concerned about things that could make its way through the request input, but also how easy it is to make mistakes, or to bypass authorization, which raises concerns in security teams. For example, it's not the same dimension having a escape hatch like this when say authorization is offloaded via a service Gateway used in a service architecture, versus a very large monolith used by thousands of engineers, or a large service-architecture with hundreds of teams -> the code that controls the escape hatch may be written once, versus hundreds of times.
m

Marcus Grass

01/12/2023, 4:04 PM
Alright I see, another question sort of on-topic here, if we'd like to avoid escape_hatches as a general rule, do you have any thoughts on specific ways of avoiding data duplication? One thing that comes to mind is, let's say the module service defers the storage of ownership to spicedb, then the module service still has to manage the data lifecycle in two places, but there's less duplication. I think that data lifecycle is the biggest unknown for us right now
I just thought of a different albeit pretty hacky solution which is that we wrap spiceDB in a service (which we were planning to do anyway) and ensure relationships exist beforehand. A bit more pseudo-code, consistency left out for brevity:
fn authorize_module(user, action, module, ctx) {
  spicedb.ensure_relationship(user, module, ctx.is_creator);
  spicedb.check_permission(...)
}
This will obviously affect query times but for our case, query times are a smaller issue than long-term data integrity in most cases.
v

vroldanbet

01/13/2023, 9:37 AM
Hi Marcus, yeah you can either make SpiceDB own all the data needed for authorization (which does not always make sense) or using CDC to replicate state from the database into SpiceDB, or in even-driven architectures using event-sourcing and a consumer that writes to SpiceDB
I'm not sure I follow what you are trying to achieve, but you could run those two things concurrently since Check has no side-effects
And I'm not sure if it helps here, but our Write and Delete APIs have "preconditions" which allows you to make sure some condition holds before writing, and happens transactionally
also we have idempotent writes through the use of
TOUCH
in case that could help with duplication