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
/meand onboarding - Roles
Owner,Manager,Staff,Receptionist
What’s inside
Section titled “What’s inside”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. |
Folder map
Section titled “Folder map”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/
- …
Endpoints
Section titled “Endpoints”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:
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);}Permissions
Section titled “Permissions”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:
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.
Events
Section titled “Events”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.
Database and wiring
Section titled “Database and wiring”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.
Extend it
Section titled “Extend it”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. |
dotnet test src/Organizations/AppointMe.Organizations.Tests