Overview — what it does
Roles and Teams are informational labels for classifying people. The one and only thing that opens access to screens and actions is the level (user_level). What a user can see, and what they may save or delete, is decided 100% by level; roles ("DBA", "DevOps", "Security") and teams ("Infra Team", "Security Team") are display/organization tags that grant no permissions whatsoever.
This keeps the permission model simple. To give someone admin power you raise their level — you do not change their role. Tagging a user "Security" does not open the security screens; their level must be Staff (5) or higher.
This article covers three things:
- Roles (
infr_role) — six job labels (DBA, DevOps, Security, Admin, Staff, Developer). - Teams (
infr_team) — organizational units, used for user membership and asset ownership. - The Owner picker — a combined dropdown that lets you assign either a user or a team, with
App\Support\Ownersas its single source of truth. The "owner" field on Instances, Hardware and Storage uses it.
Access control = level (
user_level) only. Roles and teams open no permissions programmatically. See Understanding levels (RBAC) for the level system.
When to use
- Onboarding a new person and tagging their job (role) and membership (team).
- Reorganizing — creating a team or changing its lead.
- Assigning an owner (a person or a team) to server, hardware or storage assets so "who is responsible for this" is explicit.
- Auditing / handover: looking up "who owns this asset".
Layout
/admin/users (Administration ▸ Users & Roles) has two tabs.
| Tab | Contents |
|---|---|
| Users | User list (login ID · name · email · role · team · level · status · last login) + modal CRUD |
| Teams | Team list (name · color · lead · description · member count) + modal CRUD |
The user edit modal has level / role / team selectors, followed by this hint:
Level decides access — Demo (read) · Staff (operate) · Admin (everything). Roles are for display.
How to use
Roles — job labels
The role master is six entries, upserted idempotently by key by InfrRoleSeeder.
| key | Label | Color token | seq |
|---|---|---|---|
dba |
DBA | info | 10 |
devops |
DevOps | accent | 20 |
security |
Security | crit | 30 |
admin |
Admin | warn | 40 |
staff |
Staff | muted | 50 |
developer |
Developer | good | 60 |
- A user has at most one role (
users.role_id, nullable). It may be unset. - Roles appear only as a label/color in the top-bar user dropdown and list badges.
- The display label is resolved by
App\Support\RolePolicy::roleLabel($roleId)with a 60-second cache (callRolePolicy::flush()after changes).
The role
admin(label "Admin") has nothing to do with the admin level (10). A role is just a label, so a user whose role is "Admin" but whose level is Demo (1) can save nothing. Conversely, a level-10 admin has full administration even with an empty role.
Teams — organizational units
Teams are freely CRUD-managed in the Teams tab. The four seed teams:
| Name | Color | Description |
|---|---|---|
| Infra Team | accent | Server / network / storage operations |
| Security Team | crit | Detection · response · blocking |
| Dev Team | good | Service development · deployment |
| DBA Team | info | Database operations · tuning |
Team edit fields:
| Field | Meaning |
|---|---|
Name (name) |
Required. Team name |
Color (color) |
Badge tone (accent/crit/good/info/muted …) |
Lead (lead_user_id) |
One user (optional) |
Description (description) |
Free text |
Active (is_active) |
Inactive teams are hidden from the Owner picker |
- A user belongs to at most one team (
users.team_id, nullable). - On team delete: the
team_idof that team's members is set tonull(the users remain, now unassigned). Assets that referenced the team viaowner_type='team'are a separate concern, so the assets themselves are not deleted.
The Owner picker — users + teams combined
Assets (server instances, hardware, storage) have an "owner" field that may be either a person or a team. The rules for this combined picker live in App\Support\Owners (the SOT).
Storage shape = two columns
The asset table stores the owner in two columns.
| Column | Meaning |
|---|---|
owner_id |
Integer PK of a user or a team |
owner_type |
'user' or 'team' |
Front-end exchange shape = a single ref string
The browser (forms, filters) exchanges owner_type and owner_id as one string.
user:5 → user id 5
team:3 → team id 3
The bootstrap options (Owners::options()) return the two groups for optgroup rendering (20-second cache). Closed users (is_close=1) and inactive teams (is_active=0) are excluded.
{
"users": [ { "ref": "user:5", "id": 5, "name": "Jane Doe" } ],
"teams": [ { "ref": "team:3", "id": 3, "name": "Infra Team" } ]
}
Server-side split / rebuild
| Helper | Behavior |
|---|---|
Owners::split($ref) |
"user:5" → ['user', 5]. Malformed → ['user', 0] (= unassigned) |
Owners::ref($ownerId, $ownerType) |
(5, 'user') → "user:5". Empty ownerId → null |
Owners::resolveName($ownerId, $ownerType) |
Display name for cards/lists (null if none, 20-second cache) |
Owners::flush() |
Invalidate the cache after user/team changes |
For example, the instance save controller does this:
// owner ref ("user:5"/"team:3") → owner_id + owner_type
[$ownerType, $ownerId] = Owners::split($request->input('owner_ref'));
$payload['owner_id'] = $ownerId ?: null;
$payload['owner_type'] = $ownerId ? $ownerType : 'user'; // default 'user' when unset
The same ref is used when filtering by owner — the list query splits the owner parameter (user:5/team:3) with split() and narrows to WHERE owner_id = ? AND owner_type = ?.
The Owner picker is reused identically on the Instances, Hardware and Storage & Files asset screens. Assigning a team as owner puts the whole team on the responsibility line (robust to any one person leaving).
Avatar · initials · color
The avatar shown next to a label is generated from the name by RolePolicy.
- Initials (
initials): CJK/Korean → first character; Latin names → first letter of the first and last word (2 chars). - Hue (
hue):crc32(name) % 360for a stable HSL background. - Level tone (
tierTone): admin (≥10) = crit · staff/operate (≥2) = accent · demo = muted.
Data sources
| Table | Purpose |
|---|---|
infr_role |
Role master (6 informational labels) — key·name·color·seq·is_active |
infr_team |
Team master — name·color·lead_user_id·description·is_active·seq |
users |
Users — user_level (decides access), role_id (nullable), team_id (nullable) |
| asset tables | Owner stored as owner_id + owner_type (user/team) two columns (Instances/Hardware/Storage etc.) |
SOT helpers:
App\Support\Owners— combined owner picker (options/split/ref/resolveName/flush).App\Support\RolePolicy— level labels/tones, role labels (60-second cache), avatar initials/color.
Access
- Entry to the whole screen is admin-only.
/admin/*is protected by the group middlewareinfr.level:10, andUserController/TeamControllerre-check the sessionuser_level >= 10(RolePolicy::ADMIN) themselves. Demo (1) and Staff (5) get a 403. - Level thresholds (reference): write ≥ 2 (
RolePolicy::WRITE, blocks demo) · administer ≥ 10 (ADMIN) · development/observability ≥ 9 (DEV, includes admin 10). - On user save,
user_levelis restricted to one of1,5,10. - Every user/team change is audited to
infr_audit_log(team.create/team.update/team.delete, user save/delete, etc.).
Safeguards (last-admin protection)
| Situation | Result |
|---|---|
| Demote / deactivate / delete the last active admin | Rejected — "The last active admin cannot be demoted, deactivated, or deleted." |
| Lower or deactivate your own admin level | Rejected — "You cannot lower or deactivate your own admin privileges." |
| Delete yourself | Rejected — "You cannot delete yourself." |
| Login ID already in use | Rejected — "That login ID is already in use." |
Troubleshooting & FAQ
Q. I gave someone the "Security" role but the security screens won't open. Roles are labels; they open no permissions. The security screens (mostly read) need a level — raise them to Staff (5) or higher. See RBAC levels.
Q. Some user/team is missing from the owner dropdown.
Closed users (is_close=1) and inactive teams (is_active=0) are excluded from the picker. Re-activate the user/team, or if the list is stale wait for the 20-second cache to expire (Owners::flush() invalidates it immediately).
Q. If I delete a team, do its people disappear?
No. The users remain; only their team_id becomes null (unassigned). An asset's owner_type='team' reference is separate, so the asset is not deleted either (its owner display just becomes empty).
Q. I saved an owner but no name shows.
resolveName() is a 20-second cache. If you just renamed something it refreshes shortly. Also confirm the ref format is user:5/team:3 — a broken format is treated by split() as unassigned (user:0).
Q. Can a user have two roles?
No. role_id is a single (nullable) value. If you need organizational grouping too, use a team (also a single value).
Related
- Understanding levels (RBAC) — the only axis that actually decides access
- Users & Teams console —
/admin/usersCRUD details - Instances — the flagship asset screen that uses the Owner picker
- Hardware — physical asset owners
- Storage & Files — storage asset owners
- Audit log — history of user/team/owner changes
