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
not is_protected(repository) 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:

  1. As an organization member, Alice can read unprotected repositories.
  2. As an organization member, Alice can not read protected repositories.
  3. As an organization member, Alice can read a protected repository if she's explicitly invited to it.
  4. 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"});
has_relation(Repository{"bar"}, "organization", Organization{"acme"});
is_protected(Repository{"bar"});
has_relation(Repository{"foo"}, "organization", Organization{"acme"});
is_protected(Repository{"foo"});
# 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"});
}
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
not is_protected(repository) 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!