Conditional Roles
The conditional roles pattern enables several advanced access control schemes, including default roles and toggles.
Default Roles
GitCloud is a multitenant service where countless software engineers at countless organizations collaborate on code. Organization members are automatically granted a role on all repositories in the organization. However, since organizations have different access control models, we've allowed them to choose the repository role that organization members inherit. Some organizations with tighter controls only grant the "viewer" role on their repositories, whereas organizations at the opposite end of the spectrum give all members the "admin" role on all repositories.
Without the conditional roles pattern, we might end up syncing a fact like
has_role(User{"Alice"}, <default role>, Repository{"Anvils"})
to Oso Cloud
for every single combination of user and repository across every single
organization. That's a lot of data!
Instead of syncing all that data, organizations can send a single fact like
has_default_role(Organization{"acme"}, "editor")
to set a default role for
their members to inherit on all of their repositories.
Writing rules over resource attributes (in this case, the default role configured for each organization) is a great way to reduce the data management burden in a complex authorization system.
Implement the logic
We add a custom has_role
rule that grants the organization-configured default
role on repositories to all members of the repository's organization.
actor User {}resource Organization { roles = ["member", "admin"]; permissions = ["set_default_role"]; "set_default_role" if "admin";}resource Repository { roles = ["reader", "editor", "admin"]; permissions = ["write"]; relations = { organization: Organization }; "write" if "editor";}has_role(actor: Actor, role: String, repo: Repository) if org matches Organization and has_relation(repo, "organization", org) and has_default_role(org, role) and has_role(actor, "member", org);
Test it out
The ACME organization decided "editor" should be the default role on their repositories. So ACME members like Alice can write to all ACME repositories.
test "default org role grants permission to org members" { setup { has_default_role(Organization{"acme"}, "editor"); has_role(User{"alice"}, "member", Organization{"acme"}); has_relation(Repository{"anvil"}, "organization", Organization{"acme"}); } assert has_role(User{"alice"}, "editor", Repository{"anvil"}); assert allow(User{"alice"}, "write", Repository{"anvil"});}
Toggles
We can also conditionally inherit roles by specifying attributes on the resource itself instead of on a related resource, as in the Default Roles example.
A common example of this is a toggle on the resource. For example, a setting that specifies if the resource is "protected" and restricts access accordingly.
Implement the logic
Instead of inheriting all roles unconditionally (role if role on "organization"
) or even a default role on all repositories like the previous
example, now we'll only allow users to inherit roles on repositories that
aren't marked as "protected".
actor User { }resource Organization { roles = ["admin", "member"]; permissions = [ "read", "add_member", "repository.create", ]; # role hierarchy: # admins inherit all member permissions "member" if "admin"; # org-level permissions "read" if "member"; "add_member" if "admin"; # permission to create a repository # in the organization "repository.create" if "admin";}resource Repository { permissions = ["read", "delete"]; roles = ["member", "admin"]; relations = { organization: Organization, }; "admin" if "admin" on "organization"; # admins inherit all member permissions "member" if "admin"; "read" if "member"; "delete" if "admin";}# like `role if role on "organization"`# but with an additional condition `is_protected`has_role(actor: Actor, role: String, repository: Repository) if is_protected(repository, false) and org matches Organization and has_relation(repository, "organization", org) and has_role(actor, role, org);
Test it out
There are four interesting cases to try out:
- As an organization member, Alice can read unprotected repositories.
- As an organization member, Alice can not read protected repositories.
- As an organization member, Alice can read a protected repository if she's explicitly invited to it.
- As an organization admin, Alice can read and delete all repositories regardless of protected status.
test "org members can only read repositories that are not protected" { setup { has_role(User{"alice"}, "member", Organization{"acme"}); has_relation(Repository{"anvil"}, "organization", Organization{"acme"}); is_protected(Repository{"anvil"}, false); has_relation(Repository{"bar"}, "organization", Organization{"acme"}); is_protected(Repository{"bar"}, true); has_relation(Repository{"foo"}, "organization", Organization{"acme"}); is_protected(Repository{"foo"}, true); # grant alice explicit access to foo has_role(User{"alice"}, "member", Repository{"foo"}); } assert has_role(User{"alice"}, "member", Repository{"anvil"}); assert allow(User{"alice"}, "read", Repository{"anvil"}); assert_not allow(User{"alice"}, "read", Repository{"bar"}); assert allow(User{"alice"}, "read", Repository{"foo"});}test "org admins can unconditionally read and delete repositories" { setup { has_role(User{"alice"}, "admin", Organization{"acme"}); has_relation(Repository{"anvil"}, "organization", Organization{"acme"}); is_protected(Repository{"anvil"}, true); } assert allow(User{"alice"}, "read", Repository{"anvil"}); assert allow(User{"alice"}, "delete", Repository{"anvil"});}
Extension: Combination
The two pieces of logic we just wrote combine together perfectly:
has_role(actor: Actor, role: String, repository: Repository) if is_protected(repository, false) and org matches Organization and has_relation(repository, "organization", org) and has_default_role(org, role) and has_role(actor, "member", org);
This now says that organization members inherit the organization's default repository role on all non-protected repositories. Powerful stuff!