Model Resource Fields as Resources

To implement field-level authorization, you can model each of a resource's fields as separate resources.

For alternative approaches and more context, see Field-Level Authorization.

Implementation overview

In this approach, you create an additional resource for any resource's fields. This typically involves creating new rules to express the relationship between the "parent" resource and its field resources.


resource Account {
permissions = [
# resource-level permissions
"read", "update",
];
"read" if "update";
}
resource Field {}
allow_field(user: User, action: String, account: Account, field: Field)
if ...

With this strategy, you can apply rules to specific fields by identifying them:


allow_field(user: User, action: String, account: Account, field: Field)
if field = Field{"email"}...

You can also use the same Field resource for all resources that have fields:


allow_field(user: User, action: String, wallet: Wallet, field: Field)
if field = Field{"balance"}...

While this approach can be more complex to model, it is well integrated into Oso's API. For example, it fits naturally into the query subcommand of Oso's clients (examples).

In contrast to the "fields in permissions" approach which implicitly creates resources by mentioning them in permissions, "fields as resources" explicitly creates a resource for fields.

Example

We'll model a social app with an Account whose fields we want to apply granular access to––specifically two rules that are simpler to model using field-level authorization.

User roleSpecial case
community_adminCan update other accounts' username fields, but no other fields.
visitorCan read accounts, but none of their fields.

Policy

To accomplish the conditions stated above, we'll include:

  • A Field resource:


    resource Field {
    permissions = ["read", "update"];
    "read" if "update";
    }

  • allow_field rules to correlate Accounts and their Fields, e.g.


    # allow owners to update fields with a "parent" relationship with the account
    allow_field(user: User, "update", account: Account, field: Field)
    if has_relation(account, "owner", user)
    and has_relation(field, "parent", account);

This policy shows all of the pieces working together.


actor User {}
resource Organization {
roles = ["visitor", "member", "community_admin", "admin"];
permissions = ["read", "update"];
# Role implication
# visitor < member < community_admin < admin
"visitor" if "member";
"member" if "community_admin";
"community_admin" if "admin";
# RBAC
"update" if "admin";
"read" if "visitor";
}
# Account permissions
#
# relation | read | update
# --------------------------|------|--------
# owner | ✓ | ✓
# admin on parent | ✓ | ✓
# community_admin on parent | ✓ | ✓
# member on parent | ✓ | -
# visitor on parent | ✓ | -
resource Account {
permissions = ["read", "update"];
relations = { parent: Organization, owner: User };
"update" if "owner";
"update" if "community_admin" on "parent";
"read" if "update";
"read" if "visitor" on "parent";
}
# Field permissions
#
# relation | read | update
# --------------------------|------|--------
# owner | ✓ | †
# admin on parent | ✓ | ✓
# community_admin on parent | ✓ | *
# member on parent | ✓ | -
# visitor on parent | - | -
#
# †: owner can update only defined fields on their own account
# *: community_admin can update only `Field{"username"}`
resource Field {
permissions = ["read", "update"];
"read" if "update";
}
# define the set of fields that exist
has_relation(Field{"username"}, "parent", _: Account);
has_relation(Field{"email"}, "parent", _: Account);
# allow admins to update any field, even those whose relationship with an
# account is not defined
allow_field(user: User, "update", account: Account, _field: Field)
if org matches Organization
and has_role(user, "admin", org)
and has_relation(account, "parent", org);
# allow owners to update fields with a "parent" relationship with the account
allow_field(user: User, "update", account: Account, field: Field)
if has_relation(account, "owner", user)
and has_relation(field, "parent", account);
# allow community admins to update only the username field
allow_field(user: User, "update", account: Account, field: Field)
if field = Field{"username"}
and org matches Organization
and has_role(user, "community_admin", org)
and has_relation(account, "parent", org)
# safeguard to check that user does have update on the account.
and has_permission(user, "update", account)
and has_relation(field, "parent", account);
# allow members to read all fields, n.b. visitors cannot read any fields
allow_field(user: User, "read", account: Account, field: Field)
if org matches Organization
and has_role(user, "member", org)
and has_relation(account, "parent", org)
# safeguard to check that user does have read on the account.
and has_permission(user, "read", account)
and has_relation(field, "parent", account);
test "Fields as resources" {
setup {
# admin
has_role(User{"alice"}, "admin", Organization{"example"});
has_relation(Account{"alice"}, "owner", User{"alice"});
has_relation(Account{"alice"}, "parent", Organization{"example"});
# community_admin
has_role(User{"bob"}, "community_admin", Organization{"example"});
has_relation(Account{"bob"}, "owner", User{"bob"});
has_relation(Account{"bob"}, "parent", Organization{"example"});
# member
has_role(User{"charlie"}, "member", Organization{"example"});
has_relation(Account{"charlie"}, "owner", User{"charlie"});
has_relation(Account{"charlie"}, "parent", Organization{"example"});
# visitor
has_role(User{"dana"}, "visitor", Organization{"example"});
has_relation(Account{"dana"}, "owner", User{"dana"});
has_relation(Account{"dana"}, "parent", Organization{"example"});
}
# anyone can update defined fields of their own account
assert allow_field(User{"alice"}, "update", Account{"alice"}, Field{"username"});
assert allow_field(User{"charlie"}, "update", Account{"charlie"}, Field{"email"});
assert allow_field(User{"dana"}, "update", Account{"dana"}, Field{"email"});
# admins can update all defined fields in all accounts
assert allow_field(User{"alice"}, "update", Account{"bob"}, Field{"username"});
assert allow_field(User{"alice"}, "update", Account{"charlie"}, Field{"email"});
# admins can update all fields in all accounts, including those undefined.
assert allow_field(User{"alice"}, "update", Account{"alice"}, Field{"abc"});
assert allow_field(User{"alice"}, "update", Account{"dana"}, Field{"xyz"});
# non-admin users cannot update undefined fields of thier own accounts.
assert_not allow_field(User{"bob"}, "update", Account{"bob"}, Field{"xyz"});
# community admins can only update usernames, but can read all fields
assert allow_field(User{"bob"}, "update", Account{"alice"}, Field{"username"});
assert_not allow_field(User{"bob"}, "update", Account{"alice"}, Field{"email"});
assert allow_field(User{"bob"}, "read", Account{"alice"}, Field{"email"});
assert_not allow_field(User{"bob"}, "update", Account{"dana"}, Field{"email"});
# members can only read fields from others' accounts
assert allow_field(User{"charlie"}, "read", Account{"alice"}, Field{"username"});
assert allow_field(User{"charlie"}, "read", Account{"bob"}, Field{"email"});
assert_not allow_field(User{"charlie"}, "update", Account{"dana"}, Field{"email"});
# visitors only have read access to others' accounts
assert allow(User{"dana"}, "read", Account{"alice"});
assert allow(User{"dana"}, "read", Account{"charlie"});
assert_not allow(User{"dana"}, "update", Account{"charlie"});
# visitors have no field-level access
assert_not allow_field(User{"dana"}, "read", Account{"bob"}, Field{"username"});
assert_not allow_field(User{"dana"}, "read", Account{"charlie"}, Field{"email"});
assert_not allow_field(User{"dana"}, "update", Account{"charlie"}, Field{"email"});
# granted no permissions on fields directly
assert_not allow(User{"alice"}, "read", Field{"email"});
assert_not allow(User{"alice"}, "update", Field{"username"});
assert_not allow(User{"bob"}, "update", Field{"username"});
assert_not allow(User{"charlie"}, "read", Field{"email"});
}

Limit the set of valid Field identifiers

The policy defined has_relation between some named Field resources and all Accounts:


# define the set of fields that exist
has_relation(Field{"username"}, "parent", _: Account);
has_relation(Field{"email"}, "parent", _: Account);

This provides the flexibility to:

  • Allow actions on any Field using wildcard matches (_):

    # allow admins to update any field, even those whose relationship with an
    # account is not defined
    allow_field(user: User, "update", account: Account, _field: Field)
    if org matches Organization
    and has_role(user, "admin", org)
    and has_relation(account, "parent", org);

  • Require the field have a defined relation:

    # allow owners to update fields with a "parent" relationship with the account
    allow_field(user: User, "update", account: Account, field: Field)
    if has_relation(account, "owner", user)
    and has_relation(field, "parent", account);

However, not all policies will need to define a relation between explicit field identifiers and their parent resources.

Client

By modeling fields as resources and introducing a new allow_field rule, we can use the Oso client query subcommand to determine users' field-level authorization for accounts.

To determine charlie's permissions on alice's Account:


oso-cloud query allow_field User:bob _ Account:alice Field:_


allow_field(User:bob, String:read, Account:alice, Field:_)
allow_field(User:bob, String:update, Account:alice, Field:username)

bob can read any (_) field from the alice account, but can only update the username field.

For his own account, bob can update all fields:


oso-cloud query allow_field User:bob _ Account:bob Field:_


allow_field(User:bob, String:read, Account:bob, Field:_)
allow_field(User:bob, String:update, Account:bob, Field:_)
allow_field(User:bob, String:update, Account:bob, Field:username)

The redundant update permission comes from the fact that a community_admin can edit their own username using their community_admin privileges in addition to the update permissions granted to the account owner.

Related