Skip to content
StarLive demo →
ModuleChecked against main · 31 Oct 20269 min read

Organizations module

The Organizations module owns companies, onboarding, employees, invitations and per-company role permissions, and builds the principal that every .NET 10 handler checks.

Owns
Companies, employees, invitations, permission settings
Schema
organizations, four tables
Endpoints
14, including /me and onboarding
Roles
Owner, Manager, Staff, Receptionist

Organizations knows which users work for which company, in which roles, and turns that into the principal other modules check.

Feature Folder What it does
Companies and onboarding Companies/ Company is the tenant. Onboarding creates a company and its first employee, who becomes the primary owner.
Employees Employees/ Employee links a UserId to a company with a set of roles. List the team, change roles, remove.
Invitations Invitations/ Invite an email with roles, then accept, resend or cancel. Invitations expire after 7 days.
Permission settings Settings/Permissions/ Per-company overrides on top of each role’s default grants, cached for an hour.
Current user Me/, UserAccess/ GET /me says who you are and where you work. GET /me/access lists your roles and permissions in the active company.
Principal resolution Infrastructure/ UserPrincipalFactory and PermissionResolver turn the caller into a UserPrincipal for the active company.
Contracts AppointMe.Organizations.Contracts Nine events, plus ICompanyRehydrationSource and IEmployeeRehydrationSource for other modules’ reconciliation jobs.
  • Directorysrc/Organizations/
    • DirectoryAppointMe.Organizations/
      • DirectoryAuthorization/ # OrganizationsDefaultGrantPolicy
        • …
      • DirectoryCompanies/
        • DirectoryDatabase/
          • …
        • DirectoryOnboarding/
          • …
        • DirectoryRegisterCompany/
          • …
        • Company.cs
        • …
      • DirectoryEmployees/
        • DirectoryDeleteEmployee/
          • …
        • DirectoryGetTeam/
          • …
        • DirectoryRegisterEmployee/
          • …
        • DirectoryUpdateEmployeeRoles/
          • …
        • …
      • DirectoryInvitations/
        • DirectoryAcceptInvitation/
          • …
        • DirectoryInviteEmployee/
          • …
        • …
      • DirectoryMe/ # GET /me
        • …
      • DirectoryUserAccess/ # GET /me/access
        • …
      • DirectorySettings/Permissions/
        • …
      • DirectoryInfrastructure/ # principal and permission resolution
        • …
      • …
    • DirectoryAppointMe.Organizations.Contracts/
      • …
    • DirectoryAppointMe.Organizations.Tests/
      • …

Fourteen endpoints: those with a permission also need X-Company-Id, while /me, onboarding and the invitee’s own invitation calls work before you belong to any company.

Method Route Permission Purpose
GET /api/v1/me Anonymous Who’s calling: isAuthenticated, name, email, company memberships, hasPendingInvitations, isSuperAdmin.
GET /api/v1/me/access Signed in, with a company The caller’s roles and effective permission codes in the active company.
POST /api/v1/onboarding Signed in Body { companyName, timeZone }. Creates the company and makes the caller its primary owner. Returns 201.
GET /api/v1/team employees:view Employees and pending invitations in one paged list (search, page, limit), with isPrimaryOwner and lockedRoles.
PUT /api/v1/employees/{id}/roles employees:update_roles Body { roles }. Adding or removing Owner also needs employees:manage_owners. The primary owner keeps Owner.
DELETE /api/v1/employees/{id} employees:remove Soft-deletes the employee. You can’t remove yourself.
POST /api/v1/invitations employees:invite Body { email, roles }. Inviting an Owner needs employees:manage_owners. 409 if the email is already an employee or has a pending invite.
GET /api/v1/invitations/pending Signed in The caller’s pending invitations in every company, matched by email.
POST /api/v1/invitations/{id}/accept Signed in Accepts an invitation addressed to the caller’s email and creates the employee.
POST /api/v1/invitations/{id}/resend invitations:resend Raises EmployeeInvitationResent, and Identity sends the email again.
DELETE /api/v1/invitations/{id} invitations:cancel Deletes a pending invitation. Returns 204.
GET /api/v1/settings/permissions permissions:view The matrix of every non-system permission against Manager, Staff and Receptionist, with isGranted and isOverridden.
PATCH /api/v1/settings/permissions permissions:manage Body { grants: [{ permissionKey, role, isGranted }] }. A grant that matches the default removes the override.
DELETE /api/v1/settings/permissions/overrides permissions:manage Removes every override for the company, back to the defaults.

Onboarding gives the first employee two roles. Staff makes the founder bookable, because Booking turns every Staff employee into a service provider:

src/Organizations/AppointMe.Organizations/Companies/Onboarding/Onboarding.csView source ↗
public static (Company company, Employee owner) Onboard(
CompanyName name,
TimeZoneInfo timeZone,
PersonName ownerName,
Email ownerEmail,
UserId registeredBy,
DateTimeOffset registrationDate)
{
var company = Company.Register(name, timeZone, registeredBy, registrationDate);
var owner = Employee.Register(
companyId: company.Id,
name: ownerName,
email: ownerEmail,
roles: [Role.Owner, Role.Staff],
userId: registeredBy,
registrationDate: registrationDate);
return (company, owner);
}

Nine permissions, granted by default only to Owner and Manager, and two of them are system permissions that only Owner holds.

Permission Key Owner Manager
EmployeePermissions.View employees:view ✓ ✓
EmployeePermissions.Invite employees:invite ✓ ✓
EmployeePermissions.Remove employees:remove ✓ ✓
EmployeePermissions.UpdateRoles employees:update_roles ✓ ✓
EmployeePermissions.ManageOwners (system) employees:manage_owners ✓ —
InvitationPermissions.Resend invitations:resend ✓ ✓
InvitationPermissions.Cancel invitations:cancel ✓ ✓
PermissionPermissions.View permissions:view ✓ ✓
PermissionPermissions.Manage (system) permissions:manage ✓ —

Staff and Receptionist get none of these by default. The grants live in EmployeeRolesGrants, InvitationRolesGrants and PermissionRolesGrants, and OrganizationsDefaultGrantPolicy collects them.

A SystemPermission never appears in the settings matrix, and PATCH /settings/permissions rejects it with system_managed_permission. Owner is a SystemRole, so its grants can’t be overridden either (role_permissions_immutable).

Role changes check two things beyond employees:update_roles: the roles the company locks for this employee, and whether the caller may touch system roles:

src/Organizations/AppointMe.Organizations/Employees/UpdateEmployeeRoles/UpdateEmployeeRolesCommandHandler.csView source ↗
principal.Require(EmployeePermissions.UpdateRoles);
var employee = await dbContext.Employees.LoadAsync(new EmployeeId(command.Id), companyId, cancellationToken);
var company = await dbContext.Companies.LoadAsync(companyId, cancellationToken);
employee.UpdateRoles(
command.Roles.ToHashSet(),
company.LockedRolesFor(employee.Id).ToHashSet(),
canManageSystemRoles: principal.HasPermission(EmployeePermissions.ManageOwners));

Organizations also builds the principal that every module checks. UserPrincipalFactory loads the caller’s roles in the active company and the company’s overrides from RolePermissionOverridesCache, and PermissionResolver combines them with DenyWinsPolicy. The full model is in Permissions and Identity & principal.

Why can't the primary owner lose the Owner role?1 min · optional

Only Owner holds employees:manage_owners and permissions:manage, and both are system permissions that no override can grant to another role. If every owner could be demoted, nobody could appoint an owner or change permission settings again.

Company.LockedRolesFor returns Owner for the primary owner, so UpdateRoles refuses to remove it. DeleteEmployeeCommandHandler stops you removing yourself.

Organizations publishes nine events from its Contracts project, and the only event it consumes is its own CompanyPermissionsChanged.

Event Raised by Handled by
CompanyRegistered Company.Register, during onboarding Booking upserts a BookingCompany. The API host’s DemoSeedingSaga starts when Demo:Enabled is on.
EmployeeRegistered Employee.Register, during onboarding and when an invite is accepted Booking creates or updates a service provider if the employee has Staff.
EmployeeRolesUpdated employee.UpdateRoles Booking runs the same sync. Losing Staff soft-deletes the provider.
EmployeeDeleted employee.Delete Booking soft-deletes the provider.
EmployeeInvited EmployeeInvitation.Create Identity creates the provider account and sends the email.
EmployeeInvitationResent invitation.Resend Identity sends the email again.
EmployeeInvitationAccepted invitation.Accept Nothing yet.
EmployeeInvitationCancelled invitation.Cancel Nothing yet.
CompanyPermissionsChanged Returned by the update and reset handlers CompanyPermissionsChangedEventHandler in this module drops the company’s cached overrides.

Other modules can also read Organizations directly, through two interfaces in Contracts. Booking’s reconciliation jobs call ICompanyRehydrationSource.GetAll and IEmployeeRehydrationSource.GetAllByCompany, and CRM’s demo top-up job calls GetAll. How events cross modules: Domain events & messaging.

Four tables in the organizations schema, each with a company filter except Companies, because a company is the tenant.

Table Entity Query filters Notes
organizations.Companies Company None TimeZone is stored as its id. PrimaryOwnerEmployeeId references Employees.
organizations.Employees Employee SoftDelete, CompanyId Roles is a JSON array of names. Unique on (CompanyId, Email) where not deleted. Computed SearchKey for /team.
organizations.EmployeeInvitations EmployeeInvitation CompanyId Status is stored as a string. Unique on (CompanyId, Email) while Pending. Cancelling deletes the row.
organizations.RolePermissionOverrides RolePermissionOverride CompanyId Key (CompanyId, Role, PermissionCode). Deleted with its company.

GET /me and the invitee’s calls skip only the company filter, with IgnoreQueryFilters([EmployeeFilters.CompanyId]) and its invitation equivalent. Filters are explained in Multi-tenancy.

AddOrganizationsModule() takes no configuration and binds no keys. Besides the DbContext, endpoints and permissions, it registers DenyWinsPolicy as the IOverrideConflictPolicy. GET /me reads Authentication:SuperAdmins through the SuperAdminRegistry the API host registers, and the module has no background jobs.

Roles, company settings and the conflict policy are each one or two files away.

Change Start here
Add a role Add it to Role.BuiltIn in AppointMe.Shared/Authorization/Roles/Role.cs and grant it permissions in each module’s default grant policy. Role.Create already accepts unknown names, but such a role gets no grants and isn’t in the settings matrix. See Add a permission.
Add a company setting Company, CompanyTypeConfiguration and a migration. There’s no update-company endpoint yet, so add a slice under Companies/. If Booking needs the value, add it to CompanyRegistered and CompanySnapshot.
Let a granting role win conflicts Register GrantWinsPolicy instead of DenyWinsPolicy in OrganizationsModule. Both live in AppointMe.Shared/Authorization/Permissions/OverrideConflicts.
Expire invitations Add an IRecurringJobRegistrar, call AddRecurringJobs(ModuleAssembly.Instance) in OrganizationsModule, and set InvitationStatus.Expired past ExpiresAt. See Background jobs.
Change the invitation lifetime now.AddDays(7) in Invitations/InviteEmployee/InviteEmployee.cs.

AppointMe.Organizations.Tests covers permission resolution and the role rules on employees and invitations, but no handler that touches the database.

Folder Covers
Permissions/ PermissionResolver, as when_… specifications on the Specification base class from AppointMe.Shared.Tests. Default grants, deny-wins and grant-wins conflicts, overrides for roles the user doesn’t hold, and overrides that match the defaults.
Employees/ UpdateEmployeeRoles: locked roles, system roles with and without employees:manage_owners, and custom role names.
Invitations/ EmployeeInvitation.Create: who may invite an Owner.
Companies/ CompanyOwnership: only the primary owner has Owner locked.
Terminal window
dotnet test src/Organizations/AppointMe.Organizations.Tests