Permissions
Four gates, deliberately not merged.
Gate::define('admin', fn (User $user) => (bool) $user->is_admin);
Gate::define('administer-workspace', function (User $user, ?Workspace $workspace = null) {
$workspace ??= $user->currentWorkspace();
return $user->roleIn($workspace)?->canManageMembers() === true;
});
// Whether somebody is here to do the work or to read about it.
Gate::define('work-in-workspace', function (User $user, ?Workspace $workspace = null) {
$workspace ??= $user->currentWorkspace();
return $user->roleIn($workspace)?->worksOnIssues() === true;
});
// Reading who is carrying what, and who is behind.
Gate::define('read-people', function (User $user, ?Workspace $workspace = null) {
$workspace ??= $user->currentWorkspace();
return $user->roleIn($workspace)?->canReadPeople() === true;
});
Why two
admin is the operator of Félagi itself, across every workspace. administer-workspace is an
owner or admin inside one workspace.
Collapsing them would mean a customer could only manage their own agents by being handed access to every other customer's data. A test locks the separation:
it does not grant workspace administration to a platform admin from outside — an operator has
adminbut notadminister-workspacein a workspace they are not a member of.
Roles inside a workspace
| Role | Can |
|---|---|
| Management | Read the Timeline and the reports. Nothing else, and not assigned work |
| Member | Work on issues: create, comment, assign, mention agents, run existing agents |
| Developer | Everything a member can, plus hire agents, connect machines, write skills and build autopilots |
| Admin | Everything a developer can, plus manage people and workspace settings |
| Owner | Everything an admin can, plus delete the workspace |
Management is not a rung on that ladder. The other four are ordered — each does everything the one below it does — and this one is beside them. It reads.
Which is why it is not "Member, restricted". A director handed a board and an inbox
starts appearing in the workload report as somebody carrying nothing, and the report is
then wrong for everybody who reads it. So the role cannot be handed work at all: it is
absent from every assignee picker, and assignTo() refuses it whichever door the request
came through — the interface, the API or the daemon.
Developer exists because running the machines and deciding who works here are different kinds of trust. A contractor can be handed the agent workforce without also being handed the member list; a team forced to merge the two ends up giving everyone everything.
Two gates carry the split:
| Gate | Roles | Covers |
|---|---|---|
administer-agents |
owner, admin, developer | Agents, runtimes, skills, autopilots |
administer-workspace |
owner, admin | Members, workspace settings, webhooks, import, cycles |
work-in-workspace |
everyone except management | Every working surface: the board, epics, projects, the inbox, the Gantt, cycles, timesheets, meetings, knowledge, the whiteboard, the agent roster |
read-people |
owner, admin, management | Who is carrying what and who is behind: the standup report, and the Timeline grouped by person |
write-knowledge |
everyone except management | Knowledge base articles, whiteboards |
write-issues, write-projects |
everyone except management | Editing an issue's or a project's own words, and an epic's milestones |
work-in-workspace is carried by a route group rather than by each page, so a page
added to that group tomorrow inherits it without anybody remembering this file. The
sidebar reads the same gate in one condition rather than eleven.
canManageAgents() was !== Member, which was correct for exactly as long as there
were four roles. Adding a fifth would have handed it the entire administration section,
because the new role was not the one the condition named. It is an allowlist now.
Navigation follows the gates rather than guessing: a developer is never shown a link that answers 403.
Two invariants are enforced when roles change:
- A workspace cannot lose its last owner, by demotion or removal
- Nobody can remove themselves, which would lock them out of their own workspace
Removing a membership never deletes the account or anything the person wrote.
Where each area lives
The widest gate in the product
write-knowledge grants to anybody who works here, which makes it look nearly pointless — the
check that matters is that somebody does the work, and every role but Management passes it.
It is deliberate. A knowledge base only admins may write to is a knowledge base nobody writes to, and the failure mode of documentation is always that it was never written rather than that the wrong person wrote it. The same is true of a whiteboard: the early, messy phase of a project does not survive being gated.
Two narrower rules live on the records themselves rather than in a gate, because they are about what a deletion takes with it:
| Action | Who |
|---|---|
| Delete an article | Its author, or a workspace admin — a section takes its children |
| Delete a whiteboard | Its author, or a workspace admin |
| Change who sees a whiteboard | Its author alone. The setting is about their comfort |
| Edit or delete a time entry | Its author alone. Somebody else's hours are their statement |
| Edit an agent's time entry | Nobody. The run is the record |
| Prefix | Gate | Contents |
|---|---|---|
/app |
authenticated member | The Timeline, the reports, the manual, settings |
/app (inner group) |
work-in-workspace |
Dashboard, projects, issues, epics, inbox, Gantt, cycles, timesheets, meetings, knowledge, whiteboard, agent roster |
/admin |
administer-workspace |
Members, agents, runtimes, skills, workspace settings |
/system |
admin |
All users, analytics, system information |
The agent roster stays in /app and is readable by every member. Seeing who is on the team is
part of the product, the same way the member list is; only hiring, editing and archiving are
administration.
Who is behind on what
The Standup report — everybody's open work, grouped by who is carrying it, with
overruns marked — is the one report a member cannot open. So is the Timeline grouped by
person. Both sit behind read-people: owner, admin and management.
That gate names the question rather than standing in for it. It was administer-workspace
until the management role arrived, which was the nearest existing gate and not the right
one: reading who is behind is a kind of trust an owner, an admin and a director all have,
and a developer deliberately does not. A developer hires agents, connects machines and
writes skills without being handed it.
Three doors, and each has a reason:
| Where | Why |
|---|---|
The route (can:administer-workspace) |
Where somebody auditing the application reads which routes are restricted |
| The report's own data | A Livewire update does not re-run mount(), so this is what closes on somebody demoted while the page sat open |
| The CSV export | A plain link with the filters in the query string, and the easier of the two doors to reach with a pasted URL |
The report index does not draw a tile somebody cannot use. A card that answers 403 reads as a broken product rather than as a report that is not theirs.
And the Gantt cannot be talked into it. That page binds its grouping to the query
string, so ?groupBy=assignee was one link away from the same data — as was the Gantt's
own CSV export, which read the parameter straight out of the request. Both now go through
one allowlist that names the two open groupings, so a third surface cannot reopen what
the first two closed.
The Timeline's own group parameter is bound the same way and goes through the same shape
of allowlist, built per reader: person is simply not in the list for somebody without
read-people, so asking for it in the address returns the default.
What a role that only reads cannot reach
Management is defined by its doors, so they were counted rather than remembered.
| Door | What closes it |
|---|---|
| Every working page | The work-in-workspace route group |
| Every link to one | The same gate, once, in the sidebar |
| The dashboard | It redirects to the Timeline rather than refusing. Eight places send somebody to dashboard after a login, an accepted invitation, a password change or a two-factor challenge, and a gate there would have made each of them a 403 for the person who just proved who they are |
⌘K and g-then-a-letter |
Not rendered. The palette searches issues, projects, boards and notes — every one behind the gate — so it would be a box that finds what it cannot open |
| The Gantt's CSV | A gate on its catalogue entry, which closes the card, the export and the page together |
| Attachments | roleIn(...)->worksOnIssues(), on the file route. Every attachment hangs off an issue, a comment or an article; the route takes a parameter, so the route sweep never tried it |
| The API | Refused in AuthenticateApi, with a 403 that says why. The one role that cannot open an issue could otherwise have read every issue in the workspace with a read token from its own settings page |
| Being handed work | Workspace::workers() — the relation every assignee picker reads — and assignTo() behind it |
A sweep tries every unparameterised /app route as a Management user and asserts the
exact list that answers 200. It is written as an allowlist because the failure it catches
is a new page added outside the group, and only a sweep finds the one nobody
remembered.
Joining a workspace
Everybody is invited; nobody is inserted. An administrator sends an invitation to an email address, and nothing happens to any account until the recipient accepts it themselves.
That is a deliberate reversal of the obvious design. Creating an account for somebody means choosing their first password, which means knowing it — and a colleague should not have to trust that.
- Invitation tokens are stored hashed and expire after 14 days
- Re-inviting replaces the open invitation and invalidates the previous link
- An address that already has an account joins with one click; a new one is created on the way in
- The address is fixed by the invitation, never by the form. Otherwise the link is a free pass to a pre-verified account at any address
- Accepting marks the address verified, because the link reached it
- Being signed in as somebody else does not accept on their behalf — a forwarded invitation must not put the wrong person in a workspace
Where a platform admin comes from
Not from the interface. is_admin is not fillable, nothing grants it in a form or
a route, and a test asserts that only one file in the codebase sets it — so if
somebody adds a second way, that test asks whether they meant to.
php artisan felagi:admin ops@yourcompany.com
Shell access, in other words. Platform administration reaches across every workspace, and the boundary for it is the machine rather than a role somebody can be given from inside a customer's workspace.
Revoking the last one is refused: an installation with no platform admin cannot reach its own single sign-on or allowlist settings.
Two-factor authentication is not a role
It applies to everybody who signs in, regardless of role, and the exemption list is per address rather than per account or per workspace — deliberately, since anybody can create a workspace and a workspace-scoped exemption would be a way for any user to switch off a security control.
It lives in /system, on the platform-admin gate, for the same reason.
See Two-factor authentication.
Guarded attributes
is_admin and email_verified_at are not in User::$fillable, and a test enforces it. They must
be set explicitly with forceFill, so no registration or profile-update path can ever grant
platform administration through mass assignment.
Account maintenance is not workspace administration
is_admin — the platform gate — is the only thing that may set somebody else's password
or create an account outright. A workspace owner cannot, and the distinction is worth
stating because it is easy to get backwards:
| Whose | |
|---|---|
| Remove somebody from this workspace | The workspace owner's. It is workspace-scoped |
| Change their role or weekly hours | The workspace owner's, same reason |
| Set a password on their account | The platform administrator's. A person can be in three workspaces, and owning one says nothing about their account |
Neither of them can clear another person's two-factor authentication. Nothing in Félagi can. If it could, a password reset would be an account takeover in two clicks. See Inviting your team.