Skip to content

Example: HR absences

A company's absence module built with team roles: employees ask, department managers and delegates decide, HR sees everything. A tested realm.

An HR module for a B2B app: each company is an organization, each department a team, and people ask for absences that their manager (or someone covering) approves. The whole realm is examples/config/hr, and its rules are tested with backd rules test.

Who is who

WhoIsCan
Anamember of Salesask for an absence; edit or delete it while it is requested
Martamember of Sales, manager theresee and decide Sales’ absences
Dariomember of Support, delegate in Sales for two weekssee and decide Sales’ absences in that period
Hugohr in the companysee every absence of the company

The realm

examples/config/hr/realm.yaml

# The HR absence module of docs/organizations/team-access-recipes: one company per
# organization, departments as teams, managers per department, delegation with dates.
auth: enabled
signup: open

organizations:
  roles:
    owner: { description: Runs the company account, manage: [org, members, roles, teams] }
    hr:
      description: Sees every absence of the company and approves long ones
      manage: [members, teams]
      require_mfa: true
    member: { description: Every employee }
  default_roles: [member]
  # Roles held within one department.
  team_roles:
    manager:
      description: Approves the department's absences
      grants: [delegate]        # may hand approval to someone else, in this department only
      require_mfa: true
    delegate:
      description: Approves in place of the manager, for a period
  • manager and hr require a second factor: without one they only count as plain members.
  • manager can grant delegate, in its own department.
  • There is no role for “sees the department”: inTeam and the team roles say it.

The collection and its rules

examples/config/hr/main/absences/collection.yaml

# An absence belongs to a company and to the employee's department. What each person may do
# comes from who they are in that department, never from copying names into the document.
organization: required
on_org_delete:
  action: delete
team: required
on_team_delete:
  action: keep          # the history stays, readable by HR through the organization

rules:
  # Employees see their own; the manager and delegates of the department see its absences,
  # without having to belong to it; HR sees the company's.
  read: >
    user != nil && (
      document._meta.owner == user.id
      || hasTeamRole(document._meta.team, 'manager')
      || hasTeamRole(document._meta.team, 'delegate')
      || hasOrgRole(document._meta.org, 'hr')
    )
  # Anyone asks for an absence in their own department, as a request.
  create: user != nil && inTeam(data._meta.team) && data.status == 'requested'
  # The employee edits their own request; approving needs a manager or a delegate (in force today).
  update: >
    user != nil && (
      (document._meta.owner == user.id && document.status == 'requested' && data.status == 'requested')
      || hasTeamRole(document._meta.team, 'manager')
      || hasTeamRole(document._meta.team, 'delegate')
    )
  delete: user != nil && document._meta.owner == user.id && document.status == 'requested'
  • read is filtered inside the query: Ana’s list is her own absences; Marta’s is Sales’; Hugo’s the company’s. Nobody receives a document to hide it later.
  • create needs the department in the request (?org=&team=) and a team the person belongs to, and an absence is born requested.
  • update lets the employee edit their own request and nothing more, and lets a manager or delegate decide.
  • on_team_delete: keep: when a department is deleted its absences stay, with the old team id, readable by HR.

Giving and ending cover

POST /v1/acme/_orgs/{org}/teams/{sales}/roles          # as Marta
{ "user": "<dario>", "role": "delegate", "from": "2026-08-01T00:00:00Z", "until": "2026-08-15T00:00:00Z" }

From 1 August Dario’s list of absences includes Sales’; on the 15th it doesn’t, with nothing to clean up. Marta can end it earlier with DELETE on the same path.

The tests

examples/config/hr/main/absences/rules.test.yaml

# Who sees and decides what: the employee, a delegate from another department, the manager and HR.
users:
  ana:   { id: u1, email: ana@acme.example, orgs: { acme: { roles: [member], teams: [sales] } } }
  ben:   { id: u2, email: ben@acme.example, orgs: { acme: { roles: [member], teams: [sales] } } }
  marta: { id: u3, email: marta@acme.example, orgs: { acme: { roles: [member], teams: [sales], team_roles: { sales: [manager] } } } }
  # Covers Sales from Support: holds delegate there, without belonging to it.
  dario: { id: u4, email: dario@acme.example, orgs: { acme: { roles: [member], teams: [support], team_roles: { sales: [delegate] } } } }
  hugo:  { id: u5, email: hugo@acme.example, orgs: { acme: { roles: [member, hr] } } }
  elsa:  { id: u6, email: elsa@acme.example, orgs: { acme: { roles: [member], teams: [support] } } }

documents:
  leave: { kind: vacation, from: "2026-08-03", until: "2026-08-14", status: requested, _meta: { owner: u1, org: acme, team: sales } }
  sick:  { kind: sick, from: "2026-09-01", until: "2026-09-02", status: requested, _meta: { owner: u2, org: acme, team: sales } }
  other: { kind: personal, from: "2026-09-10", until: "2026-09-10", status: requested, _meta: { owner: u6, org: acme, team: support } }

tests:
  - name: an employee sees and edits only their own request
    as: ana
    read: { allow: [leave], deny: [sick, other] }
    update:
      - { document: leave, patch: { note: "Moved to August" }, expect: allow }
      - { document: leave, patch: { status: approved }, expect: deny }
    delete: { allow: [leave] }
    create:
      - { data: { kind: vacation, from: "2026-12-20", until: "2026-12-31", status: requested }, org: acme, team: sales, expect: allow }
      - { data: { kind: vacation, from: "2026-12-20", until: "2026-12-31", status: approved }, org: acme, team: sales, expect: deny }
      - { data: { kind: vacation, from: "2026-12-20", until: "2026-12-31", status: requested }, org: acme, team: support, expect: deny }

  - name: the manager of Sales approves Sales' absences, and only theirs
    as: marta
    read: { allow: [leave, sick], deny: [other] }
    update:
      - { document: leave, patch: { status: approved }, expect: allow }
      - { document: other, patch: { status: approved }, expect: deny }

  - name: a delegate from another department decides for Sales without being in it
    as: dario
    read: { allow: [leave, sick], deny: [other] }
    update:
      - { document: sick, patch: { status: approved }, expect: allow }

  - name: HR reads the whole company and doesn't decide for a department
    as: hugo
    read: { allow: [leave, sick, other] }
    update:
      - { document: leave, patch: { status: approved }, expect: deny }

  - name: another department's employee sees nothing of Sales
    as: elsa
    read: { allow: [other], deny: [leave, sick] }

The fixture says what each person is in orgs: roles, teams and team roles, as the server would load them for a request. A delegate whose window has closed is simply a user without that role.

Around it

  • Onboarding: an on_member_joined hook creates the employee’s profile, subscribed to their department.
  • Emails in the company’s name: with company context and owner templates, the “absence requested” and “absence decided” emails carry the company’s brand, and each company edits its own text.
  • The app: client.db('main').collection('absences').inOrg(org).inTeam(team).create({...}) in the JavaScript client; the department’s managers come from client.orgs.teams(org).members(team, { role: 'manager' }).
Edit this page · Last updated 15 Oct 2026