Role Management¶
The role management system controls who has access to what across Alan's tooling. It is built on four pillars: role definitions, role grant rules, role requests, and role grants.
Role Definitions¶
A role definition declares a role that can be assigned to users. All role definitions inherit from BaseRoleDefinition (in models/role_definition.py).
Attributes¶
| Attribute | Type | Default | Description |
|---|---|---|---|
role_id |
str |
required | Unique identifier, e.g. "alan:tools:alaner", "org:area-security:member" |
description |
str |
required | Human-readable description |
can_be_requested |
bool |
True |
Whether the role appears in the request UI |
self_service_permitted_for |
set[EmployeePermission] |
set() |
Permissions that allow self-service (auto-approved) requests |
self_service_duration |
int |
0 |
Max self-service grant duration in hours |
grant_management_access_policy |
type[AccessPolicy] \| list \| None |
None |
ABAC policy for who can approve/deny requests |
slack_handle_to_ping |
Optional[str] |
None |
Slack handle for approval notifications; falls back to community security referent, then security_oncall |
role_grant_rule_ids()¶
Each role definition implements role_grant_rule_ids() to declare which rule IDs automatically grant the role. This links AlanHomeRole rows to RoleGrantRule rows via the role_grant_rule_policy junction table during the daily sync.
class AlanToolsBackendAlaner(RoleDefinition):
role_id = "alan:tools:alaner"
description = "Alan employee"
@classmethod
def role_grant_rule_ids(cls) -> list[str]:
return [AlanerBaselineRoleGrantRule.rule_id]
Dynamic Role Generation (RoleDefinitionGenerator)¶
Static roles subclass RoleDefinition directly. For roles that depend on dynamic entities (areas, crews, units, oncall groups), a RoleDefinitionGenerator produces one BaseRoleDefinition subclass per entity at sync time.
class RoleDefinitionGenerator(ABC):
@classmethod
@abstractmethod
def role_definitions(cls) -> list[type[BaseRoleDefinition]]:
pass
Built-in generators:
| Generator | Role ID pattern | Source |
|---|---|---|
AreaMemberRoleGenerator |
org:area-{slug}:member |
Active areas |
UnitMemberRoleGenerator |
org:unit-{slug}:member |
Active units |
CrewMemberRoleGenerator |
org:crew-{slug}:member |
Active crews |
OncallGroupMemberRoleGenerator |
org:oncall-group-{slug}:member |
Active oncall groups |
Each generated class sets __metadata__ with the entity ID (e.g. {"area_id": "..."}) for use in ABAC policies.
Discovery (get_all_role_definitions()) collects all static RoleDefinition subclasses and all generator outputs. The result is cached for 10 minutes.
Role Grant Rules¶
A role grant rule defines a set of users who should automatically receive a role. Rules come in two flavors.
Static Rules (RoleGrantRuleDefinition)¶
Subclass RoleGrantRuleDefinition and implement grantees_definition():
class AlanerBaselineRoleGrantRule(RoleGrantRuleDefinition):
rule_id = "baseline-alaner"
description = "Grant parent role to all Alaners"
@classmethod
def grantees_definition(cls) -> set[AlanUser]:
return set(current_session.execute(
select(Alaner).where(not_(Alaner.is_ended))
).scalars())
Dynamic Rules (RoleGrantRuleGenerator)¶
For rules that depend on dynamic entities, a RoleGrantRuleGenerator produces one BaseRoleGrantRuleDefinition per entity:
class RoleGrantRuleGenerator(ABC):
@classmethod
@abstractmethod
def role_grant_rules(cls) -> dict[str, type[BaseRoleGrantRuleDefinition]]:
pass
@classmethod
def rule_ids_for(cls, *names: str) -> list[str]:
return [cls._rule_id_for(name) for name in names]
Built-in generators:
| Generator | Rule ID pattern | Grantees |
|---|---|---|
CommunityBaselineRoleGrantRuleGenerator |
baseline-community-{slug} |
Community members + extras |
AreaBaselineRoleGrantRuleGenerator |
baseline-area-{slug} |
Area members |
CrewBaselineRoleGrantRuleGenerator |
baseline-crew-{slug} |
Crew members |
UnitBaselineRoleGrantRuleGenerator |
baseline-unit-{slug} |
Unit members |
OncallBaselineRoleGrantRuleGenerator |
baseline-oncall-{slug} |
Oncall group members |
Role definitions reference dynamic rules via rule_ids_for():
@classmethod
def role_grant_rule_ids(cls) -> list[str]:
return [*CommunityBaselineRoleGrantRuleGenerator.rule_ids_for("engineering", "data")]
Role Requests¶
A RoleRequest (models/role_request.py) represents a user asking for a role. Key fields: role_id, requester_id, grantee (polymorphic: Alaner, ExternalUser, or ServiceAccount), starts_at, ends_at, request_reason.
Request Approval Flow¶
flowchart TD
A[User submits request] --> B{Self-service eligible?}
B -->|yes: has permission + duration ok| C[Auto-approve]
B -->|no| D[Post to #role_requests on Slack]
D --> E[Ping slack_handle_to_ping or security referent]
E --> F{Approve or Decline?}
F -->|Approve| G[Mark approved_at + approver_id]
F -->|Decline| H[Mark rescinded_at + rescinder_id]
C --> I[Create RoleGrant]
G --> I
I --> J[Trigger provider sync]
H --> K[Revoke linked grant if any]
Self-service auto-approval: If the grantee holds a permission listed in self_service_permitted_for AND the requested duration is within self_service_duration, the request is instantly approved with the grantee as their own approver.
Manual Slack approval: Posts a message to #role_requests with Approve/Decline buttons. Pings the role's slack_handle_to_ping if set, otherwise the requester's community security referents, falling back to security_oncall.
Grant creation: Once approved, add_role_grant_for_pending_request() creates a RoleGrant with role_request_id set.
Rescission: A rescinded request marks rescinded_at/rescinder_id and revokes any linked grant by setting its ends_at to now.
Role Grants¶
A RoleGrant (models/role_grant.py) is the actual record that a user holds a role during a time window (starts_at to ends_at).
Two Sources¶
Every grant must come from exactly one source, enforced by a CheckConstraint:
| Source | Field set | ends_at behavior |
|---|---|---|
| Rule-based | rule_id |
Typically None (permanent); revoked by setting ends_at = now() when user leaves the rule's grantees |
| Request-based | role_request_id |
Set from the request's ends_at |
No-Overlap Constraint¶
PostgreSQL ExcludeConstraint on tsrange(starts_at, ends_at, '[)') prevents overlapping grants for the same user + role + rule combination:
EXCLUDE USING gist (
grantee_alaner_id WITH =,
role_id WITH =,
rule_id WITH =,
tsrange(starts_at, ends_at, '[)') WITH &&
)
Active Status¶
is_active hybrid property: starts_at <= now AND (ends_at IS NULL OR ends_at >= now). Works both in Python and SQL.
Daily Sync (update_role_grants)¶
The update_role_grants command runs daily at 5:00 AM UTC (weekdays) and reconciles all grants in five sequential steps:
flowchart TD
A[update_role_grants] --> B[1. revoke_unknown_grants]
B --> C[2. revoke_ended_users_grants_and_requests]
C --> D[3. cancel_stale_requests]
D --> E[4. update_rule_based_grants]
E --> F[5. update_request_based_grants]
| Step | Function | What it does |
|---|---|---|
| 1 | revoke_unknown_grants() |
Ends grants whose role_id is not in AlanHomeRole or rule_id is not in RoleGrantRule (handles deleted roles/rules) |
| 2 | revoke_ended_users_grants_and_requests() |
For ended Alaners/ExternalUsers: sets ends_at = min(today, last_day). Rescinds pending requests with "User has left the company" |
| 3 | cancel_stale_requests() |
Rescinds unapproved requests where ends_at <= today with "Request has expired" |
| 4 | update_rule_based_grants() |
Per role+rule: creates grants for new grantees, revokes grants for removed grantees, removes stale rule links |
| 5 | update_request_based_grants() |
Creates grants for approved-but-unfulfilled requests; revokes grants for rescinded requests |
Related Sync Commands¶
Two prerequisite commands run earlier to keep metadata in sync:
| Command | Schedule | Description |
|---|---|---|
update_role_grant_rules |
:45 * * * 1-5 |
Syncs RoleGrantRule rows from all rule definitions and generators |
update_roles |
:50 * * * 1-5 |
Syncs AlanHomeRole rows from get_all_role_definitions(), links to rules via role_grant_rule_ids() |
Notifications¶
The notify_of_role_removals command runs at 5:10 AM UTC (weekdays) and handles two cases.
7-Day Expiry Reminder¶
- Finds active grants expiring in ~7 days (adjusted for weekends on Mondays).
- Skips if an extension grant already exists or if a permanent rule-based grant covers the role.
- Sends a Slack DM to the grantee (or external user's referent) with a link to request an extension.
On-Expiry Notification¶
- Finds grants that became inactive in the last 24 hours.
- Checks that the user does not hold the same role via another active grant.
- Sends a Slack DM notifying the role has expired with a link to request again.
Mapping to Providers¶
Each UserLifecycleProvider declares a role_mapping that connects role definitions to provider-specific identifiers:
class GoogleProvider(UserLifecycleProvider):
role_mapping = {
GoogleSuperAdminRole: {"10526070683992065"},
GoogleIamAdminRole: {"10526070683992067", "..."},
}
get_target_roles(user)¶
Iterates role_mapping, checks which roles the user holds (via has_role()), and returns the flattened set of provider-specific identifiers. This is compared against the user's actual roles on the provider to compute additions and removals.
flowchart LR
A[get_target_roles] --> C[Set diff]
B[get_all_users → current roles] --> C
C --> D[target - current = add]
C --> E[current - target = revoke]
D --> F[Provider API calls]
E --> F
Async Task Triggering on Grant Change¶
When a role request is approved or rescinded, process_user_lifecycle_task_related_to_role_request() finds all tasks whose provider's role_mapping includes the affected role_id and enqueues an async RQ job (process_user_lifecycle_tasks_for) with Retry(max=3). This triggers the provider's update_roles() to immediately reconcile the user's roles without waiting for the daily batch.