Setting up role-based access control for subcontractor portals
A subcontractor portal is the one surface where parties outside your organization read and write live project data — RFIs, submittals, change orders, and the budget lines those documents move. This page covers exactly one slice of that surface: how to bind every portal request to the scope of work named in the subcontractor’s executed contract, so an access attempt is authorized against a real boundary — the work breakdown structure segments, cost codes, and document types that party is actually contracted for — before it ever touches a project endpoint. Generic enterprise role-based access control (RBAC) is not enough here, because a subcontractor’s “view change order” right is never global; it is scoped to their WBS segments and their budget codes for the duration of their contract. The rules that make this predictable are part of the broader construction data architecture and taxonomy, and the boundary primitives they build on come from security boundary configuration. Get the scoping wrong and a drywall sub reads the mechanical contractor’s markups; get the contract window wrong and an expired vendor keeps pulling RFIs for months.
Key rules and specification
Author each control as an explicit, testable rule rather than a loose permission flag. Deny-by-default is the spine: a request is authorized only when it passes every check below, and any single failure denies with a specific, auditable reason. The table is the minimum viable boundary contract for a subcontractor session.
| Control | Rule | Why it matters |
|---|---|---|
role |
enum subcontractor_lead / subcontractor_field / subcontractor_accounting |
Closed role set; no wildcard or inherited roles that silently widen access |
action |
enum bound to role via a least-privilege matrix | A role maps to an explicit action set; unknown actions deny |
resource_wbs |
regex ^PROJ-\d{3}-\d{2}-\d{2}$, must be in the contract’s segments |
Stops lateral movement into another trade’s scope of work |
resource_budget_code |
MasterFormat ^\d{2} \d{2} \d{2}$, must be in the contract’s codes |
Keeps financial visibility inside contracted cost centers |
| contract window | valid_from <= now <= valid_to, all timezone-aware UTC |
An expired or not-yet-active contract grants nothing |
| denial payload | safe fallback route + structured audit record, never partial data | Compliance trail and no leakage of internal routing or metadata |
Two rules carry the most weight. The resource_wbs membership check is the boundary that prevents cross-trade exposure — without it, a valid role becomes a project-wide read. And the contract window must be enforced with timezone-aware timestamps, because a naive local valid_to is the classic way an access boundary drifts by hours across distributed field crews. The WBS element pattern itself (PROJ-NNN-DIV-NN) follows the conventions defined in WBS mapping strategies, and the budget codes are the MasterFormat sections standardized under budget code standardization — the access layer trusts those formats to already be normalized.
Production access evaluator
The module below mirrors the boundary contract in Pydantic v2 models and evaluates a portal request deterministically. Roles and actions are Literal types so an unknown value fails at the model boundary, not deep in business logic; the WBS and budget patterns are regex-validated Fields; and the contract window is forced timezone-aware in a field_validator. The evaluator runs the gates in fixed order and, on any failure, returns a structured AccessDecision carrying a safe fallback route and an audit record — never partial project data. A request that clears the deterministic policy is then gated by a legitimacy-confidence score so that anomalous-but-permitted attempts (impossible travel, a burst of post-hours pulls) follow the pipeline’s canonical bands: at or above 0.92 the session auto-routes, 0.75–0.92 is held for step-up review, and below 0.75 it is quarantined and alerted regardless of the role’s nominal rights.
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator
logger = logging.getLogger("construction.rbac")
Role = Literal["subcontractor_lead", "subcontractor_field", "subcontractor_accounting"]
Action = Literal[
"view_rfi", "submit_rfi", "view_submittal",
"submit_submittal", "view_change_order", "view_budget",
]
# Least-privilege matrix: each role maps to an explicit, CLOSED action set.
ROLE_ACTIONS: dict[str, frozenset[Action]] = {
"subcontractor_lead": frozenset(
{"view_rfi", "submit_rfi", "view_submittal", "submit_submittal", "view_change_order"}
),
"subcontractor_field": frozenset({"view_rfi", "submit_rfi", "view_submittal"}),
"subcontractor_accounting": frozenset({"view_change_order", "view_budget"}),
}
WBS_PATTERN = r"^PROJ-\d{3}-\d{2}-\d{2}$" # PROJ-NNN-DIV-NN, e.g. PROJ-118-03-30
BUDGET_PATTERN = r"^\d{2} \d{2} \d{2}$" # MasterFormat XX XX XX, e.g. 03 30 00
class ContractScope(BaseModel):
"""The signed-contract boundary a subcontractor's portal session is bound to."""
model_config = {"extra": "forbid"}
subcontractor_id: str = Field(pattern=r"^SUB-\d{4,6}$")
valid_from: datetime
valid_to: datetime
wbs_segments: frozenset[str]
budget_codes: frozenset[str]
@field_validator("valid_from", "valid_to", mode="before")
@classmethod
def require_tz_aware(cls, v: Any) -> datetime:
"""Contract windows MUST be timezone-aware or the boundary drifts across crews."""
if isinstance(v, str):
v = datetime.fromisoformat(v.replace("Z", "+00:00"))
if isinstance(v, datetime) and v.tzinfo is None:
raise ValueError("contract window timestamps must be timezone-aware (UTC)")
return v
class AccessRequest(BaseModel):
"""A single portal action, validated at the boundary before any gate runs."""
model_config = {"extra": "forbid"} # reject client-injected fields outright
user_id: str
role: Role
project_id: str = Field(pattern=r"^PROJ-\d{3}$")
action: Action
resource_wbs: str = Field(pattern=WBS_PATTERN)
resource_budget_code: str = Field(pattern=BUDGET_PATTERN)
at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class AccessDecision(BaseModel):
status: Literal["ALLOW", "DENY"]
reason: str
http_status: int
route: str
audit: dict[str, str]
def _deny(req: AccessRequest, reason: str, http_status: int) -> AccessDecision:
decision = AccessDecision(
status="DENY",
reason=reason,
http_status=http_status,
route="/api/v1/portals/access-denied", # safe fallback, leaks no project routing
audit={
"user_id": req.user_id,
"project_id": req.project_id,
"action": req.action,
"resource_wbs": req.resource_wbs,
"violation": reason,
"at": req.at.isoformat(),
},
)
logger.warning("RBAC deny: %s", decision.model_dump_json())
return decision
def evaluate_access(req: AccessRequest, scope: ContractScope) -> AccessDecision:
"""Deny-by-default: a request is allowed only if it clears EVERY gate, in order."""
# 1. Contract validity window (tz-aware comparison only).
if not (scope.valid_from <= req.at <= scope.valid_to):
return _deny(req, "CONTRACT_OUTSIDE_VALIDITY_WINDOW", 403)
# 2. WBS boundary: the request must target a contracted segment.
if req.resource_wbs not in scope.wbs_segments:
return _deny(req, "WBS_SCOPE_MISMATCH", 403)
# 3. Budget boundary: financial visibility stays inside contracted codes.
if req.resource_budget_code not in scope.budget_codes:
return _deny(req, "BUDGET_SCOPE_MISMATCH", 403)
# 4. Least-privilege action check against the closed role matrix.
if req.action not in ROLE_ACTIONS[req.role]:
return _deny(req, "INSUFFICIENT_ROLE_PRIVILEGES", 403)
decision = AccessDecision(
status="ALLOW",
reason="POLICY_SATISFIED",
http_status=200,
route=f"/api/v1/projects/{req.project_id}/{req.action}/{req.resource_wbs}",
audit={"user_id": req.user_id, "subcontractor_id": scope.subcontractor_id,
"action": req.action, "at": req.at.isoformat()},
)
logger.info("RBAC allow: %s", decision.model_dump_json())
return decisionBecause both inputs are validated models, the evaluator can trust that req.role is a known role and resource_wbs already matches the project pattern; its only job is to compare the request against the contract boundary. Emitting the decision with model_dump_json() gives you a machine-readable line for a SIEM or compliance dashboard, and it satisfies the audit-trail intent of NIST SP 800-53 AC-3 (least privilege) without bolting logging on as an afterthought.
Common mistakes and gotchas
- Wildcard or inherited roles. The moment a role maps to
"*"or inherits from a broader template, “view change order” stops meaning “view my change orders.” KeepROLE_ACTIONSa closedfrozensetper role and let theLiteraltype reject anything else at the model boundary — a closed matrix is the cheapest defense against privilege creep as new actions are added. - Naive contract-window timestamps. A
valid_tostored without a timezone compares against a UTCnow()and silently grants (or revokes) access by hours, which on a multi-site job means an expired sub still pulls RFIs after midnight UTC. Force every window timestamp timezone-aware in afield_validator(mode="before"), the same way RFI schema design normalizes its ISO 8601 fields, so the boundary is unambiguous everywhere. - Trusting client-supplied scope. If the portal accepts an
access_tier,role, or extra field from the request body, a crafted payload escalates itself. Setmodel_config = {"extra": "forbid"}so unexpected keys are rejected, and resolve theContractScopeserver-side from the authenticated identity — never from the request. - Leaky denials. Returning a 404 from the real resource route, or echoing back the project metadata the request named, hands an attacker a map of what exists. Every denial must route to one neutral fallback and carry its detail only into the audit log, not the HTTP response body.
Integration pointer
This evaluator is the gate that sits in front of every subcontractor-facing endpoint, downstream of the boundary primitives defined in security boundary configuration and upstream of the project tracking database. The ContractScope it enforces is populated from the same WBS and cost-code taxonomy that the rest of the pipeline relies on, so the resource_wbs it checks is exactly the key produced by how to map CSI MasterFormat to custom WBS codes in Python, and the budget codes are the ones reconciled in standardizing budget cost codes across Procore and Sage 300. When a request is denied or an anomalous session is quarantined, the audit payload feeds fallback alert routing so the right project controller is notified rather than the event vanishing into a log file.
Frequently asked questions
Should the role-to-action matrix live in code or in the database?
Keep the shape in code as a closed Literal-typed matrix so unknown roles and actions fail at the type boundary, and keep the per-contract scope (WBS segments, budget codes, validity window) in the database, resolved server-side from the authenticated identity. Mixing the two — storing free-text role names in the database and trusting them — is how wildcard permissions creep in. The code defines what roles can exist; the contract record defines what this particular subcontractor’s session is bound to.
How do I enforce the contract validity window correctly across time zones?
Store valid_from and valid_to as timezone-aware UTC, compare against a UTC datetime.now(timezone.utc), and reject any naive timestamp at the model boundary. A naive valid_to is the most common cause of an expired subcontractor retaining access (or an active one being locked out) because the comparison silently assumes the server’s local zone. Normalize on the way in, never at comparison time.
What should a denied request actually return to the portal?
One neutral fallback route and a generic status — never the real resource path, the project metadata the request named, or a 404 that confirms a resource exists. Put the violation reason, user, and targeted WBS into the structured audit record instead. The user sees “access denied”; your compliance dashboard and fallback alert routing see the full forensic detail.
Where do the confidence bands fit if the policy is already deterministic?
The deterministic policy decides whether a request is permitted; the confidence score decides whether a permitted request is trustworthy enough to auto-route. A legitimacy score at or above 0.92 auto-routes, 0.75 to 0.92 holds the session for step-up review (re-authentication or document-control approval), and below 0.75 quarantines and alerts. This catches a valid credential being used anomalously — impossible travel, post-expiry retries — without weakening the hard policy gates.
Related
- Security Boundary Configuration — the boundary primitives this access layer builds on
- How to map CSI MasterFormat to custom WBS codes in Python — produces the WBS keys this evaluator scopes against
- Standardizing budget cost codes across Procore and Sage 300 — normalizes the budget codes the boundary checks
- Designing fallback routing for disconnected field devices — where denial and quarantine alerts are routed
- Best practices for structuring RFI JSON payloads for APIs — the payload contract these scoped endpoints serve
← Back to Security Boundary Configuration