rfd/0243-scoped-roles-in-access-lists.md
This RFD describes a design for supporting the assignment of scoped roles at specific assigned scopes from access lists. This does not describe a design for making access lists scoped themselves.
Access lists currently allow the assignment of roles as follows:
kind: access_list
metadata:
name: example-list
spec:
grants:
roles:
- editor
- auditor
version: v1
With this change scoped roles will be assignable at specific scopes:
kind: access_list
metadata:
name: example-scoped-list
spec:
grants:
scoped_roles:
- role: ops-staging-access
scope: /ops/west
- role: ops-prod-access
scope: /ops/west
version: v1
Recommended Reading:
Related Reading:
Scoped roles are a new scoped variant of traditional roles that can be used to
grant privileges at specific scopes.
They currently must be granted to users via a scoped_role_assignment for each user.
Allowing access lists to grant a set of scoped roles to all list members will
make it easier to assign scoped roles to groups of users and is another step on
the path to bring feature parity to the new scoped authentication/authorization
model.
Examplecorp operates out of two regions, east and west. Each region has their own groups of admins and users. They want to grant each admin group broad administrative privileges over everything in their region, and each user group access to servers in their region, without allowing admins or users in each region to modify or access anything in the other region.
They have defined the following scoped roles that they want to grant to their users:
kind: scoped_role
metadata:
name: ops-admin
scope: /
spec:
assignable_scopes:
- /ops/**
rules:
- resources:
- scoped_role
- scoped_role_assignment
verbs: [create, list, readnosecrets, update, delete]
---
kind: scoped_role
metadata:
name: ops-staging-access
scope: /
spec:
assignable_scopes:
- /ops/**
node_labels:
- name: 'env'
values: ['staging']
logins: ['opsuser', 'root']
---
kind: scoped_role
metadata:
name: ops-prod-access
scope: /
spec:
assignable_scopes:
- /ops/**
node_labels:
- name: 'env'
values: ['prod']
logins: ['opsuser']
But now they are faced with the challenge of assigning these roles to all their thousands of users. They have all their users and groups already defined in their IdP and are already importing their user groups as Teleport access lists using one of our existing integrations (Okta, Entra), and now they want to assign these scoped roles to user groups.
The access lists they are already importing with members are west-admins,
west-users, east-admins, and east-users.
To assign the scoped roles defined above to members of these existing access
lists, they define new access lists granting the scoped roles, and then make
their existing imported lists members of the new lists.
Here are the list definitions:
kind: access_list
metadata:
name: west-admins-scoped
spec:
title: "west admins scoped"
grants:
scoped_roles:
- role: ops-admin
scope: /ops/west
version: v1
---
kind: access_list
metadata:
name: west-users-scoped
spec:
title: "west users scoped"
grants:
scoped_roles:
- role: ops-staging-access
scope: /ops/west
- role: ops-prod-access
scope: /ops/west
version: v1
---
kind: access_list
metadata:
name: east-admins-scoped
spec:
title: "east admins scoped"
grants:
scoped_roles:
- role: ops-admin
scope: /ops/east
version: v1
---
kind: access_list
metadata:
name: east-users-scoped
spec:
title: "east users scoped"
grants:
scoped_roles:
- role: ops-staging-access
scope: /ops/east
- role: ops-prod-access
scope: /ops/east
version: v1
Then to make all users of the existing groups members of these new access lists, they create the list memberships:
# make west-admins a member of west-admins-scoped
tctl acl users add --kind=list west-admins-scoped west-admins
# make west-users a member of west-users-scoped
tctl acl users add --kind=list west-users-scoped west-users
# make east-admins a member of east-admins-scoped
tctl acl users add --kind=list east-admins-scoped east-admins
# make east-users a member of east-users-scoped
tctl acl users add --kind=list east-users-scoped east-users
These access lists and memberships could also be managed with Terraform instead
of tctl.
Initially, users will be able to add scoped role grants to access lists via
tctl, terraform, and the k8s operator.
Scoped role grants in access lists will be viewable with tctl get access_list/name.
We will need to add the ability to view and edit scoped role assignments in the web UI. The name and assigned scoped of granted scoped_roles will be viewable on the access list page. When editing granted permissions, a scoped role input will be presented if the cluster contains any scoped roles defined in the root scope. The editor will not allow adding required permissions if the role contains any scoped_role grants, and will not allow adding scope_role grants if the role has any required permissions (see invariants).
Scoped roles are currently assigned to users via scoped_role_assignment
resources that look like the following:
kind: scoped_role_assignment
metadata:
name: uuid1
scope: /
spec:
user: [email protected]
assignments:
- role: ops-admin
scope: /ops/west
- role: ops-staging-access
scope: /ops
version: v1
The above would grant to the [email protected] the following roles:
ops-admin in scope /ops/westops-access in scope /opsAccess lists will not really be an alternative to scoped role assignments, rather, they will essentially be a way of automating the definition of scoped role assignments for many users at once.
For example, if you wanted to make both [email protected] and [email protected]
admins over the /ops/west scope, you could define an access list as follows:
kind: access_list
metadata:
name: west-admins
spec:
title: "west admins"
grants:
scoped_roles:
- role: ops-admin
scope: /ops/west
- role: ops-access
scope: /ops
version: v1
---
kind: access_list_member
metadata:
name: a3e64073-8980-41aa-a7a0-dd23de40c38e
spec:
access_list: west-admins
name: [email protected]
membership_kind: MEMBERSHIP_KIND_USER
version: v1
---
kind: access_list_member
metadata:
name: c4c9d4b3-c57c-40de-a15e-e2425267a716
spec:
access_list: west-admins
name: [email protected]
membership_kind: MEMBERSHIP_KIND_USER
version: v1
Under the hood the scoped access cache would materialize from this access list the following scoped role assignments:
kind: scoped_role_assignment
sub_kind: materialized
metadata:
name: acl-AAPNguHLcuy8snJ8vO0YbXj1rGGleXuKhbN8Yw
scope: /
spec:
user: [email protected]
assignments:
- role: ops-admin
scope: /ops/west
- role: ops-access
scope: /ops
status:
origin:
creator: access_list
creator_name: west-admins
version: v1
---
kind: scoped_role_assignment
sub_kind: materialized
metadata:
name: acl-dBC_S6bIUGqMzbvVn_uHTN-NNAiI2q7KreU9gw
scope: /
spec:
user: [email protected]
assignments:
- role: ops-admin
scope: /ops/west
- role: ops-access
scope: /ops
status:
origin:
creator: access_list
creator_name: west-admins
version: v1
These "materialized" scoped role assignments would be inserted into the scoped role assignment cache. Everything that already works with scoped role assignments today will not need to know or care about access lists, they will just handle the resulting assignments as usual.
We will introduce sub_kind: materialized to distinguish materialized
scoped_role_assignments from user-created ones and prevent name collisions.
We will use sub_kind: static for the current user or automation-created
scoped_role_assignments (this will be a breaking change that has been deemed
acceptable at this early stage).
Scoped role assignments will use <name>/<sub_kind> as a primary key in the
backend and the scoped access cache.
Materialized assignments will be named acl-<hash> where hash is an
unambiguous deterministic hash of the username, listname pair.
Specifically it will be a Base64URL encoded SHA224 hash over
(len(username) || username || listname)
Access list members can either be users or other access lists. When listA has member listB, it means that all members of listB are inherited members of listA. In this case listA has delegated part of its membership definition to listB. In general, a user is a member of listA if they are an explicit member of listA or they are a member of any list that is an explicit or inherited member of listA.
For example, consider an access list named west-access that grants access to
SSH servers in the /ops/west scope.
If an admin wanted to grant SSH access to all members of the west-users and
west-admins lists, they could make west-users and west-admins members of
the west-access list.
In doing this, the west-access list would be delegating the definition of
its user members to its member lists.
All members of west-users and west-admins would become inherited members of
the west-access list.
Nested list memberships effectively form a graph where nodes are access lists and edges are access list memberships.
Access lists can grant privileges not only to members but also to owners of the
list under the owner_grants field, this will also support scoped role grants.
kind: access_list
metadata:
name: owner-grants-example
spec:
title: "owner grants"
owner_grants:
scoped_roles:
- role: ops-admin
scope: /ops/west
- role: ops-access
scope: /ops
owners:
- name: [email protected]
membership_kind: MEMBERSHIP_KIND_USER
- name: admins
membership_kind: MEMBERSHIP_KIND_LIST
version: v1
Access list owners are listed directly in the access list spec, they can either
be direct user owners or other lists can be named as owners.
When an access list a is named as an owner of access list b, all members
of a become owners of b and receive b's owner grants.
Owners of a do not become owners of b.
The term "materialization" is used here to mean the computation and storage of concrete scoped role assignments from their source of truth, which is the current set of access lists and access list members. Rather than referencing access lists (and memberships) and traversing the graph during scoped login events, the set of materialized scoped role assignments will be computed ahead of time and stored in the scoped role assignment cache. The reasoning for this is to ensure that teleport can efficiently determine the full set of scoped privileges in user-facing hot paths, without the need to relogin. Being able to efficiently discover scopes at which a user has privileges without requiring reauthentication is critical for a good user experience.
Every (user, list) pair, where user is an explicit or inherited member or owner of list and list grants scoped roles, will result in 1 materialized scoped role assignment. Each materialized assignment for (user, list) will grant exactly the scoped roles defined in the spec of that list (for members, owners, or both depending on the user's relationship with the list).
The scope of the materialized assignment will be the root scope /.
As an unscoped policy resource, access lists have an authority/provenance
equivalent to the / (root) scope.
If we materialized assignments s.t. they were owned by a scope lower than root,
that would permit assignment editing permissions in child scopes to "reach up"
and change the intent/effect of the higher level access list policy, which
would violate scope isolation/hierarchy.
For example, if alice is an explicit member of listA, and listA is an explicit member of listB, then alice is member of both listA and listB. 2 scoped role assignments will be materialized, one for (alice, listA) and another for (alice, listB).
If listB is an owner of listC, then alice's membership in listB makes her an owner of listC, and a scoped role assignment will be materialized for (alice, listC).
The materialized assignments will be initialized along with the scoped access cache and kept up to date via a backend watcher on access lists, access list members, and scoped roles. The implementation will gracefully handle access list graphs of an arbitrary depth that may contain cycles, although performance may degrade in diabolical cases. Performance characteristics will be benchmarked once this system is implemented to ensure they are reasonable (TBD: reasonable performance characteristics).
Note: if many users are members of many lists, this could result in a lot of
materialized assignments.
For example, if 20k users are all members of 1k lists all of which grant scoped
roles, this would result in 20 million materialized scoped role assignments.
In general the number of assignments will be approx (num users) x (avg lists per user).
If scale becomes a concern here we could consider aggregating scoped role
assignments for each user, so there would be approximately 1 materialized
scoped role assignment for each user, more if they are assigned too many unique
roles to fit in a single gRPC message and the assignments need to be batched.
In this case if 20k users were members of any number of lists across 20 scopes,
there would be approximately 20k materialized assignments.
The downsides here would be that the source of the materialized assignment would
be more difficult to reason about, the cache would be more difficult to
maintain as access lists and memberships change, and batching large assignments
would introduce additional complexity.
The current plan is not to aggregate scoped role assignments.
Earlier in a design for scoped access lists there was discussion of writing these materialized assignments out to the cluster's backend database. For the time being, we are opting not to do this and instead keep the materialized assignments in the scoped access cache only.
Some reasons not to store materialized assignments in the backend are:
Due to the requirement of being able to efficiently discover scopes at which a user has privileges without requiring reauthentication and the resulting scoped role assignment materialization strategy, some limitations will apply to access lists that assign scoped roles and their member lists.
We do not want scoped role assignments to depend directly on user traits, which
are dynamic and may change on relogin as they are assigned by an external IdP.
To maintain this, any access list that grants a scoped role must not include any
membership_requires or ownership_requires block.
These blocks normally prevent access list membership from applying based on
user traits.
This limitation will apply to access lists that directly grant scoped roles,
and any of their member lists (which would transitively grant scoped roles).
This will be enforced during access list and access list member creation on a best effort basis since strict enforcement would require full access list graph traversal while holding a global lock. If membership or ownership requires blocks end up being added to an access list that grants scoped roles, the scoped_role_assignment materializer will detect the change, log the invalid list, and stop materializing scoped_role_assignments for memberships in that list.
For example, given member-list which is a member of parent-list where only
parent-list grants scoped roles:
parent-list can be easily statically prevented.member-list (which does not itself grant scoped
roles) should not be allowed. If this can't be prevented at the backend layer
then the materializer will stop considering any membership paths that go
through member-list, as if the list itself does not exist.
Non-scoped role grants will not be affected (they will still apply).The current implementation of the scoped_role_assignment resource is too
strict/opinionated to work well with access lists.
In particular, the current implementation of scoped_role_assignment resources
does not have a well-defined model for handling invalid or dangling assignments
that may be possible while changes are propagating through the cache, such as
an assignment referencing a deleted role or attempting to assign it at a scope
that is no longer permissible.
While this limitation does not directly block access list implementation, scoped role assignments via access lists may be broken or error-prone in some edge cases. The plan is to implement a model for handling invalid or dangling assignments where all assignments will be checked when they are loaded, and invalid assignments will be dropped.
Also, in order for the scoping model to be fully consistent, access lists must only be able to assign roles defined in the root scope '/'. This is necessary to prevent admins of only specific scopes from modifying a role assigned by an access list defined in the root scope, and follows from the current limitation that scoped_role_assignments must have the same scope of definition as each role that they assign. Defining scoped_roles in the root scope is currently not allowed, we will need to update/allow this.
Teleport already has multiple integrations with third party identity providers that automatically import groups and create Access Lists to model them in Teleport with bidirectional sync. These include AWS IAM Identity Center, Microsoft Entra ID, Okta, and SailPoint.
At least initially, we will not alter these integrations to automatically create access lists granting scoped roles. Instead, admins can create other access lists to grant scoped roles, and define the IdP imported access lists as members of those lists.
Access lists will technically be allowed to grant both regular and scoped roles:
kind: access_list
metadata:
name: example-list
spec:
grants:
roles:
- editor
- auditor
scoped_roles:
- role: ops-staging-access
scope: /ops/west
- role: ops-prod-access
scope: /ops/west
version: v1
When users do a "regular"/unscoped login, they will receive the unscoped roles as usual. When they do a scoped login at a specific scope, they will receive all roles granted in scopes non-orthogonal to the scope they're logging in to (this is the effect of the materialized scoped role assignments).
These invariants will be enforced at two levels:
The scoped access backend already has an AtomicWrite strategy for handling
writes to scoped_roles and/or scoped_role_assignments while enforcing this kind
of invariant.
As access lists act similarly to scoped_role_assignments, they can use the same
strategy.
Namely, a value at backend key /scoped_role/role_lock/<role-name> is used to
synchronize writes to the named scoped role and all assignments referencing the
role.
Create and Delete on scoped_role_assignments atomically assert the revision
of each referenced scoped_role, and modify the lock value for each role so that
writes to scoped_roles can efficiently detect concurrent modification to assignments.
Create/Update/Delete on scoped_roles atomically assert that the revision of
this lock has not been modified between the time the invariants are checked and
the time the role is written.
AccessList Create and Update will check that all referenced scoped roles
exist, are defined in the root scope, and are assignable at the assigned scope.
They will use AtomicWrite to assert the checked revision of each referenced
scoped role, and modify /scoped_role/role_lock/<role-name> for each referenced
role to make sure it isn't modified immediately after an access list create/update.
This requires 2 conditions per referenced role, which will limit the number of
roles a single access list can reference.
This same limitation applies to scoped role assignments already, the current
limit is 16.
UpdateScopedRole may modify a role's assignable scopes.
In this case it will check that the change doesn't invalidate any assignments
in extant access lists, and will continue to use AtomicWrite to verify that
/scoped_role/role_lock/<role-name> hasn't concurrently changed.
DeleteScopedRole will continue to use AtomicWrite to verify that
/scoped_role/role_lock/<role-name> hasn't concurrently changed to verify that
no new access lists reference it.
membership_requires or ownership_requiresSee limitations. UpsertAccessList and UpdateAccessList will statically verify that the list cannot contain both scoped role grants and requirement blocks.
membership_requires or ownership_requiresUpdateAccessList (if it adds a scoped role assignment or requirement block), and access list member creation methods, will have to traverse the access list graph to assert that no transitive members of access lists that grant scoped roles can contain requirement blocks.
It's likely to be prohibitively expensive to do this check while holding a global backend lock (and it's not clear if it's possible to implement with AtomicWrite) so this check will be best-effort. If the invariant is violated, access lists with requirement blocks will not result in scoped role assignments being materialized (they will be dropped by the assignment materializer and the invalid list or membership will be logged). See limitations.
These are not being written to the backend and thus cannot use AtomicWrite. These invariants will be enforced when the scoped access cache is initialized, and when access lists and their members are created/updated/deleted. To avoid non-deterministic behavior while scoped role assignments are initially being materialized, process readiness will be gated on the scoped access cache being ready.
The changes described here do not affect privacy.
--- a/api/proto/teleport/accesslist/v1/accesslist.proto
+++ b/api/proto/teleport/accesslist/v1/accesslist.proto
@@ -169,6 +169,19 @@ message AccessListGrants {
// traits are the traits that are granted to users who are members of the
// Access List.
repeated teleport.trait.v1.Trait traits = 2;
+
+ // scoped_roles are the scoped roles that are granted to users who are
+ // members of the Access List.
+ repeated ScopedRoleGrant scoped_roles = 3;
+}
+
+// ScopedRoleGrant describes a scoped role granted at a specific scope.
+message ScopedRoleGrant {
+ // role is the name of the scoped role to be granted.
+ string role = 1;
+ // scope is the scope the role will be assigned at. It must be an assignable
+ // scope of the role.
+ string scope = 2;
}
The current design materializes scoped role assignments for each (user, list) pair where user is a member of list, this could scale to large numbers of resources that will consume memory on the auth service. This is discussed in Materialization of scoped role assignments.
In case of cluster downgrade, the new scoped_roles field, if present in any
access list grants, will not be seen by older auth server versions and should
not cause any validation problems.
In case we decide to persist materialized scoped_role_assignments to the
backend in a future version, all backend reads will filter out assignments with
sub_kind: materialized so that they don't conflict with auth servers on an
older version still materializing in memory only.
No new audit events will be created or emitted as no new resources or actions are being added.
Traces will be added for scoped role assignment materialization to monitor performance. Metrics will be added for the number of materialized scoped role assignments.
TBD, scopes are currently rolling out to limited design partners.
Scoped role assignment via access lists with and without nested memberships will be added to the test plan.