Skip to content
Blog DevOps

How I Give Each Administrator Access to Exactly Their Part of the Organisation

By Romi Fadli, S.Kom. 16 min read Security & Secrets
On this page
An assignment card linked to one unit inside one agency; the unit and its sub-units are in scope, the rest of the agency and the other agency are not

Introduction

A. Many administrators, one set of records

A personnel system for a city government is used by many administrators, and almost none of them should see everything. The central personnel office manages records for the whole government. Each agency has its own administrators, who manage the employees of that agency and nobody else. Large agencies go further: a school or a health clinic may have its own administrator for the people who work there. Next to them are reviewers, who check what employees submit about themselves — a new family member, a training certificate, a change of rank — and service officers, who decide on salary steps, promotions and retirement.

All of them work on the same records. An agency administrator who opens the employee list must see their agency's employees and only those. A reviewer who checks family data for one agency must not be able to approve a submission from another, and must not be able to approve their own. The question "may this person do this?" therefore always has a second half: "to which part of the organisation?"

The challenge is to give each administrator and reviewer access to exactly their part of the organisation — all of it, one agency, or one unit with the units below it — and to make the granting of that access itself controlled and recorded.

The second half of that sentence matters as much as the first. Access rights are a target in their own right: whoever can grant a role can grant themselves anything. So the system has to answer not only who may edit a record, but who may give someone that ability, who must approve it, for how long it lasts, and what happens when the person moves to another agency.

B. What the national guidance asks for

The national civil-service agency has issued a circular on managing access to personnel information systems. I used three of its patterns as requirements: access is requested and approved rather than simply assigned; the person who verifies is not the person who approves; and access is granted for a limited period, at most two years, after which it must be renewed. These patterns are familiar from general security practice too — separation of duties and time-limited privileges appear in every access-control framework — but having them written down by the regulator removed any debate about whether the extra steps were worth it. The circular also shaped the system's vocabulary: request, verification, approval and expiry became recorded states of a grant rather than steps in an informal process that only people remember.

C. The approaches I rejected

The simplest approach is plain roles: an "Agency Administrator" role and a check that the user has it. It answers "what" but not "where". An agency administrator would see every agency's employees unless each query added its own condition, and the condition would live in dozens of places, each a chance to forget it.

The next approach is a role per agency: "Administrator of the Health Agency", "Administrator of the Education Agency", and so on. It puts the scope into the role's name. I rejected it because the number of roles multiplies with every agency and every unit, the scope cannot be read by code without parsing a name, and an agency that is reorganised leaves a trail of roles named after units that no longer exist.

The role library I use offers a "teams" feature that attaches roles to a team. It was the closest ready-made option, and I rejected it as well: it assumes one team per request, it has no notion of one scope containing another, and it has nowhere to record who requested a role, who approved it and when it expires.

The approach I chose treats the scope as data. A grant is a row: this user, this role, at this scope. Code asks about permissions, never role names, and every permission check is made against the scope of the data being touched.

D. Fifteen obstacles in the way

Easy to state, awkward to build. Fifteen things stood between "roles and permissions" and access that fits the organisation exactly.

The first is that a role alone says what, not where. Roles named after agencies and a team feature without hierarchy are the rejected alternatives.

The second is that scopes are nested. Writing a separate rule for each combination of grant and target is the rejected alternative.

The third is that a unit scope must follow the organisation tree. Granting access to a list of individual units is the rejected alternative, because it misses units added later.

The fourth is keeping role names out of the code. Checks such as "is this user an agency administrator?" are the rejected alternative.

The fifth is knowing the scope of the data being touched. Storing an owner agency on every record is the rejected alternative.

The sixth is that lists must be filtered, not only single actions. Checking each row after loading the whole table is the rejected alternative.

The seventh is that one person may hold several scoped roles. Allowing only one role per user is the rejected alternative.

The eighth is that access must be requested and approved. Letting an administrator assign roles directly is the rejected alternative.

The ninth is separation of duties between roles. Letting the people who approve access also work on the personnel records is the rejected alternative.

The tenth is that access must end on its own. Access that lasts until someone remembers to remove it is the rejected alternative.

The eleventh is that access must follow the person's placement. Keeping an agency role after its holder moves to another agency is the rejected alternative.

The twelfth is that the history of access must survive. Deleting a role assignment when it ends is the rejected alternative.

The thirteenth is the emergency super administrator. A super administrator who also does the daily work is the rejected alternative.

The fourteenth is that the interface must know what to show. Deciding visibility from role names in the browser is the rejected alternative.

The fifteenth is notifying the right people. Notifying everyone who holds a reviewer role is the rejected alternative.

The order runs from the model outward. The first seven define what a grant is and how it is checked; the eighth to the thirteenth govern how grants are given, ended and recorded; the last two carry the result to the interface and to notifications. A model that cannot say "where" makes every later control meaningless, which is why it comes first.

Method

A. A grant is a row: user, role, scope

This is the answer to the first obstacle: access to personnel data is never a bare role. It is an assignment — a row that names the user, the role and the scope at which the role applies, with its status, its validity period and the people who requested, approved or revoked it.

sql
CREATE TABLE role_assignment (
  id          BIGSERIAL PRIMARY KEY,
  user_id     BIGINT NOT NULL REFERENCES users(id),
  role        TEXT   NOT NULL,             -- key in the role catalogue
  scope       TEXT   NOT NULL,             -- operator | agency | unit
  agency_id   BIGINT REFERENCES agency(id),
  unit_id     BIGINT REFERENCES org_unit(id),
  valid_until TIMESTAMP,
  status_id   BIGINT NOT NULL REFERENCES assignment_status(id)
);

Because the scope is a column, code can read it, query it and compare it, and adding a new agency needs no new role.

B. Nested scopes, decided in one place

Scopes form a chain. The system scope covers everything. The operator scope — the central personnel office — covers every agency. An agency scope covers one agency and every unit in it. A unit scope covers one unit and the units below it. Whether a grant reaches a piece of data is decided by one small function, and every check in the system goes through it.

php
public function covers(Scope $target): bool
{
    return match ($this->type) {
        self::SYSTEM   => true,
        self::OPERATOR => in_array($target->type, [self::OPERATOR, self::AGENCY, self::UNIT], true),
        self::AGENCY   => in_array($target->type, [self::AGENCY, self::UNIT], true)
                          && $target->agencyId === $this->agencyId,
        self::UNIT     => $target->type === self::UNIT && isset($this->coverage[$target->unitId]),
    };
}

C. A unit scope follows the tree

A unit grant names one node of the organisation tree, and its coverage is that node plus all of its descendants, computed when the grant is loaded. A school administrator therefore reaches new classes or sub-units the moment they are added, without a change to the grant. When the agency adopts a new version of its structure, the grant's anchor is moved to the node that continues the old one, so a reorganisation does not silently strip anyone of access or leave them pointing at a unit that no longer exists.

D. Code asks about permissions, never role names

Permissions are named domain.action — family.view, family.review, rank.manage — and roles are defined in a catalogue as bundles of permissions with the scopes they may be granted at. Application code only ever asks whether the user has a permission for a target. A new role, such as a reviewer for training records, is a new catalogue entry and needs no change to the code that checks access.

php
// the controller names the permission and the data, never a role
Gate::authorize('family.review', $employee);

The framework's gate hands every permission name in the catalogue to my authorisation service, which collects the user's grants for that permission and asks each one whether it covers the target.

E. The scope of the data is derived

Records do not carry an owner agency of their own. The scope of a piece of data is derived from the people it is about. An employee's scope is their unit, or their agency if they are not yet placed in a unit. A family member who is not an employee belongs to the scope of the employees they are related to — a spouse, a parent or a child. A marriage record belongs to the scope of either partner. A person with no link to any employee is reachable only from the central office.

Deriving the scope means it cannot fall out of date. When an employee moves to another agency, their family's records move with them, because nothing about those records had to be updated.

F. Lists are filtered with one condition

A single action is checked against one target, but a list must be filtered before it is loaded. All of the user's scopes for a permission are merged into one reach: either everything, or a set of agencies and a set of units. That reach becomes one condition on the query.

php
if ($reach->everything) return $query;

return $query->where(fn ($q) => $q
    ->when($reach->agencies, fn ($w) => $w->orWhereIn('agency_id', $reach->agencies))
    ->when($reach->units, fn ($w) => $w->orWhereIn('unit_id', $reach->units)));

An empty reach produces a condition that matches nothing, so a user whose grants reach nowhere sees an empty list, and nobody sees a list that was filtered after the fact.

G. Several roles, one combined answer

One person can hold several assignments: an agency administrator who is also a family reviewer for a single school, for example. Their grants are kept side by side, each with its own permissions and scope. A permission is allowed for a target when any grant that carries it covers that target, and a list is filtered by the union of those grants' scopes. Nothing has to be flattened into one role.

H. Requested, then approved

Following the national guidance, nobody assigns a role directly. An administrator with the right to request roles at a scope submits a request with a reason. A separate person approves or rejects it. The requester cannot request a role for themselves, and neither the requester nor the grantee can decide the request. At approval the conditions are checked again — the grantee's account and placement may have changed since the request was made — and the approver confirms with their password.

I. Separation of duties between roles

The role that approves access, the account manager, has no permissions on personnel data, and a person who holds it cannot be granted a personnel role, in either direction. Whoever controls access therefore cannot use it on the records, and whoever works on the records cannot approve their own access. The same principle reaches the reviews themselves: a reviewer cannot decide a submission about themselves or about their spouse, parents or children.

J. Access ends on its own

Every assignment has an end date, two years by default and never more. A daily job marks expired assignments, and from that moment they grant nothing. Renewal is a new request that points at the assignment it extends, approved in the same way, so continuing access is a decision someone makes again rather than a default that nobody reviews.

K. Access follows placement

A role at an agency or unit scope can only be granted to someone whose account is active, linked to an employee record, and placed in that agency. When the holder moves to another agency, the assignments whose scope no longer matches are revoked automatically. When an account is detached from its employee record, all of its assignments are revoked. Each automatic revocation records why it happened.

L. The history is never deleted

An assignment row is one lifecycle: requested, then active, rejected or cancelled; and if active, later revoked or expired. Rows are never deleted, so the system can always answer who had which access, at which scope, who approved it and when it ended. To prevent two live copies of the same grant, each active assignment carries a key derived from its user, role and scope, and the key is unique.

php
// only an assignment that currently grants access holds the key
$a->active_key = $a->grantsAccess()
    ? hash('sha256', implode('|', [$a->user_id, $a->role, $a->scope, (int) $a->agency_id, (int) $a->unit_id]))
    : null;

A unique index on that column lets the database refuse a duplicate even if two requests are approved at the same moment, while ended assignments, whose key is empty, never collide.

M. The emergency super administrator stands apart

System roles are kept apart from personnel roles. The administrator role holds every permission and exists for emergencies, not daily work. Personnel roles are never system roles, so they cannot be handed out without a scope by mistake. When a role must be granted outside the request-and-approve flow — to bootstrap the first approver, for instance — it is done through a command-line task that requires a written reason and creates an ordinary assignment row with its history.

N. The interface reads permissions, the server decides

When a user signs in, the API returns their permissions with a compact scope for each: all, or the lists of agencies and units they reach. The interface uses them to decide which menus and buttons to show. It never looks at role names.

typescript
export const can = (user: AuthenticatedUser | null, permission: string): boolean =>
  user !== null && permission in user.permissions

export const canForAgency = (user: AuthenticatedUser | null, permission: string, agencyId: number): boolean => {
  const scope = user?.permissions[permission] ?? null
  return scope === 'all' || (scope?.agency ?? []).includes(agencyId)
}

Hiding a button is a convenience, not a control. Every request is checked again on the server, and a request outside the user's scope is refused there regardless of what the interface showed.

O. Notifications go to whoever is in scope

When an employee submits a change, the people notified are those who hold the permission to review it and whose scope covers that employee. A family reviewer for one school hears about submissions from that school and not from the whole city. Notifications use the same grants and the same coverage rule as every access check.

Results and Discussion

A. What the model gives

Every question about access has the same shape: a permission, a target, and the user's grants. Single actions, filtered lists, the interface's menus and the routing of notifications all use one coverage rule. A new agency or unit needs no new role; a new kind of reviewer is one catalogue entry; and a reorganisation moves grants along with the structure instead of breaking them.

The lifecycle gives the other half of the challenge. Access is requested with a reason, approved by someone else, limited in time, revoked when the holder moves, and kept in a history that is never deleted.

B. What it costs

The model asks more of administrators than ticking a role on a user. Each grant needs a request, a reason and an approval, and it must be renewed. Deriving the scope of data also means some checks look at related records — a family member's scope comes from the employee they are linked to — which costs queries that a stored owner column would not. I accepted both: the first is the control the guidance asks for, and the second is what keeps scopes correct when people move.

C. Where this approach fits

The approach fits any system in which the same kind of user works on different parts of a hierarchical organisation: branches of a bank, schools in a district, departments of a hospital. For a small system with a handful of users it is more than needed; there, a few plain roles are enough, and the request-and-approve lifecycle would be ceremony.

Conclusion

Access in this system is a set of rows, each saying who, which role and where. Scopes nest from the whole system down to a unit and its descendants, one function decides whether a grant covers a target, code asks only about permissions, and lists are filtered by the union of a user's scopes. Grants are requested and approved by different people, separated from the role that approves them, limited to two years, revoked when their holder moves, and never deleted.

The result is that each administrator works on exactly their part of the organisation, and the question of how they got that access always has a recorded answer.

Terms

Scope
The part of the organisation a grant applies to: the whole system, every agency, one agency, or one unit with the units below it.
Assignment
A row granting one role to one user at one scope, with its validity period and its request and approval history.
Permission
A named action on one kind of data, such as reviewing family records; roles are bundles of permissions.
Separation of duties
The rule that no single person both controls an action and checks it, such as granting access and using it.
Agency
A department of the city government with its own employees and administrators.

Technologies covered

  • PHP
  • TypeScript
  • Laravel
  • Vue

About the author

Photo of Romi Fadli

Romi Fadli, S.Kom.

Solution Architect · Backend Engineer · Embedded Systems

In my day job I work as a solution architect. I design the system, lead the team that builds it — quality assurance, backend developers, a UI developer, DevOps and DevSecOps — and write the most critical modules myself, such as a certificate authority, a long-term PDF signer, and the core API of a personnel system.

I work across the whole stack that a running system actually needs. On the hardware side that means PCB design, microcontrollers, and embedded firmware — including RISC-V boards running FreeRTOS and Embassy, speaking MQTT over mutually authenticated TLS on a microcontroller with 64 KB of SRAM.

Most of my day-to-day work is software: Rust backends and API modules built on Clean Architecture, an OAuth2/OpenID Connect authorization server written from scratch, PAdES document signing, and certificate authorities — root CAs, timestamping authorities, and OCSP responders. I also build the frontends that sit on top of them.

Running all of that means infrastructure: Docker Compose stacks, networking, Mikrotik and RADIUS, observability with OpenTelemetry, and a steady habit of writing command-line tools and utilities for Linux when the existing ones do not fit.

Share

Enjoyed this?

Get an email when a new article is published.

Referenced by

Employees and administrators use the Web App, which talks to the Core API; the Core API uses storage, regions, face and preview services, sends documents to the Aurograf signing platform, sends logs and traces to the log stack, and imports from the old system

SIMPEG

Programming

The personnel information system of the City Government of South Tangerang: employees keep their own records and follow their services, administrators work on exactly their part of the organisation, and every rule comes from the regulation that sets it. Led and built by me since 2023.

10 min read Read more →
One definition in the middle feeds both a phone layout with stacked cards and a bottom bar, and a desktop layout with a top menu and a table

The Vue browser application of a civil-service personnel system: a self-service portal where employees keep their records and propose changes, and administration of a versioned organisation chart, reference data and accounts — built from a few shared definitions, not a page per record.

9 min read Read more →
The core API at the centre, fed by storage, address, face and preview services, with documents sent across a boundary to an external signing platform

The Laravel core of a civil-service personnel system: employee records and career histories, self-service proposals with review, salary-step, promotion and retirement decisions, rules taken from regulations, a versioned organisation chart, and access scoped down to a single unit.

11 min read Read more →

Related Articles

Employees and administrators use the Web App, which talks to the Core API; the Core API uses storage, regions, face and preview services, sends documents to the Aurograf signing platform, sends logs and traces to the log stack, and imports from the old system

SIMPEG

Programming

The personnel information system of the City Government of South Tangerang: employees keep their own records and follow their services, administrators work on exactly their part of the organisation, and every rule comes from the regulation that sets it. Led and built by me since 2023.

10 min read Read more →
One definition in the middle feeds both a phone layout with stacked cards and a bottom bar, and a desktop layout with a top menu and a table

The Vue browser application of a civil-service personnel system: a self-service portal where employees keep their records and propose changes, and administration of a versioned organisation chart, reference data and accounts — built from a few shared definitions, not a page per record.

9 min read Read more →
The core API at the centre, fed by storage, address, face and preview services, with documents sent across a boundary to an external signing platform

The Laravel core of a civil-service personnel system: employee records and career histories, self-service proposals with review, salary-step, promotion and retirement decisions, rules taken from regulations, a versioned organisation chart, and access scoped down to a single unit.

11 min read Read more →