Surplus logo
Surplus Docsby Sharing Excess

Authorization & access control

Authentication proves who you are. Authorization decides what you can do. Surplus enforces authorization on the server, on every request, in layers.

Roles

Every user has one permission level (UserPermission in packages/utils):

RoleAccess
adminFull administrative access.
advisorRead-oriented oversight — for example user lists, cost-sharing agreements, and tags.
partnerA donor or recipient organization member; scoped to their own organizations' data.
driverField operations — assigned routes, stops, and the events on them.
suspendedA user whose permission is null. Blocked from every protected endpoint.

Permissions are loaded fresh from the database on each request, so a change to someone's role takes effect immediately (see Authentication).

Layer 1 — Route middleware

Every protected endpoint applies authMiddleware (apps/server/src/helpers/middleware.ts). It rejects unauthenticated requests (401) and suspended accounts (403 "Account is suspended") before the handler runs. Passing roles restricts the endpoint further:

// any signed-in, non-suspended user
.use(authMiddleware())

// admins only
.use(authMiddleware('admin'))

// admins and advisors
.use(authMiddleware('admin', 'advisor'))

If roles are listed and the user's role isn't among them, the request is rejected with 403 "Insufficient permissions."

Layer 2 — Handler and resource checks

Role membership alone isn't always enough — a partner who is allowed to view their organization must not see another organization's data. Endpoints that return organization-scoped data add ownership checks in the handler, confirming the caller is linked to the organization in question (through the donor/recipient association tables) before returning anything. A partner requesting data for an organization they don't belong to is rejected even though their role is otherwise permitted.

Layer 3 — File access control

Files are never public. They're served only through the authenticated API, and apps/server/src/routes/files/assertFileAccess.ts authorizes each request by the file's S3 key prefix and the caller's relationship to the underlying entity:

  • Admins and advisors — unrestricted.
  • collectionEvents/{id}/… — allowed only if the user is linked to the event's donor, or is a driver assigned to its route.
  • distributionEvents/{id}/… — allowed only if the user is linked to the event's recipient, or is a driver assigned to its route.
  • routes/{id}/… — allowed only if the user is a driver on the route, or is linked to a donor or recipient on one of its stops.

Requests for a missing entity get 404; unauthorized requests get 403.

Roadmap note. Prefixes outside those listed above are currently allowed for authenticated users and are slated for tighter, prefix-specific checks in a future pass. This is called out directly in the source so it isn't mistaken for a finished control.

Attachment visibility

Attachments carry a visibility flag (internal or external). Internal attachments are visible only to admins, advisors, and drivers; external attachments are also visible to partners. This lets staff keep operational notes separate from what a partner organization can see.

Why this design holds up

  • One enforcement path. Authorization lives in shared middleware and a small set of helpers, not copy-pasted across handlers — so it's auditable and hard to bypass.
  • Server-side only. The client hides controls a user can't use, but the server is the authority. A crafted request from a lower-privileged user still hits the same checks.
  • No privileged tokens. Because roles aren't in the session token, there's no way to forge elevated access by replaying or tampering with a cookie.