Skip to content

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]
Hold "Alt" / "Option" to enable pan & zoom

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]
Hold "Alt" / "Option" to enable pan & zoom
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

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
Hold "Alt" / "Option" to enable pan & zoom

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.