Roles & Access Control
Smart EPD connects multiple organizations (manufacturers, tool providers, verifiers, and consultants) in shared workspaces. The permission model ensures every participant sees exactly what they need and can act only within their designated scope.
Organization types
Every organization on Smart EPD has a type that determines its role:
| Type | Primary responsibility | Typical workspace role |
|---|---|---|
| Manufacturing Group | Owns products and projects, submits EPDs for verification | Reporting Organization |
| Tool Provider | Registers tools in Categories, defines specifications, responds to verifier feedback | Tool Provider |
| Consultant | Performs LCA work and EPD authoring on behalf of manufacturers | Consultant |
| Verifier | Reviews specifications, templates, and EPDs for PCR compliance | Verifier |
Permission layers
Access is determined at four levels, each scoping the next:
1. Organization membership
Every user belongs to exactly one organization with an internal role:
- Admin -Manages organization settings, members, and billing
- Member -Participates in workspaces and projects
2. Workspace membership
When an organization joins a workspace, all its members automatically receive viewer access. Individual users can be elevated:
- Admin -Manages workspace settings, Categories, organization memberships
- Editor -Creates and modifies projects, EPDs, submittals
- Viewer -Read-only access to all workspace content
3. Organization role in workspace
Each organization has exactly one role per workspace. These roles are not tied to permissions, but are used for documentation purposes. Typical responsibilities include:
| Role | What they typically do in the workspace |
|---|---|
| Reporting Organization | Creates projects, authors EPDs, submits for verification |
| Tool Provider | Manages tool registration and specifications within Categories |
| Consultant | Authors EPDs and LCA documents on behalf of the reporting org |
| Verifier | Conducts section-level review, fills checklists, approves or flags items |
| Member | Default role for organizations without a specific designation; participates in workspace activities |
An organization can hold different roles in different workspaces. For example, a large firm might be a reporting organization in one workspace and a consultant in another.
4. Project team membership
Individual users are explicitly added to project teams. Team membership controls:
- Can edit EPDs -Whether the user can create and modify EPD content
- Project visibility -Only team members can view project content and participate in project chat
How access cascades
- Organization → Workspace: Automatic. All org members get viewer access when the org joins.
- Workspace → Category: Inherited. Workspace access extends to all Categories within it.
- Category → Project: Inherited + explicit. Workspace users get access. Other external users must be added to project teams individually.
- Project → EPD: Inherited from the project level
This layered model ensures that:
- A tool provider can see all Categories in a workspace but only edit the ones where their tool is registered
- A verifier can access all projects within their assigned Categories but cannot modify EPD content
- A manufacturer's external team members only see the specific projects they're assigned to
Verifier-specific access
Verifiers have a unique access pattern. They are assigned to:
- Categories -For tool-level verification (verifying the tool and EPD Template)
- Submittal packages -For per-item review (verifying individual EPDs and LCAs)
Once assigned, verifiers see these items in their verification queue: a consolidated view across all active assignments.
Verifiers can:
- Read all EPD content for their assigned items
- Set conformance status per section
- Create verification comments
- View AI conformance hints
- Fill verifier checklists
- Approve, flag, or request revision on items
Verifiers cannot:
- Edit EPD content directly
- Create or delete EPDs
- Modify template constraints (they can approve or reject proposed constraints)
API-level enforcement
Every v2 API endpoint is gated against a specific scope. Scopes are grouped into org-level and workspace-level sets, and each role (admin, editor, viewer) is granted a frozen scope set.
Scope sets by role
| Scope group | viewer | editor | admin |
|---|---|---|---|
| Org-level | organizations:read, plants:read | + plants:write | + organizations:admin |
| Workspace-level | workspaces:read | + workspaces:write | + workspaces:members, workspaces:invites |
Roles are evaluated per-resource. A user can be admin in org A and viewer in org B; the scope check runs against the specific org_id or workspace_id in the request path. There is no cascading — org-admin does not grant workspace-admin, and vice versa.
Capability matrix
| Capability | Viewer (org) | Editor (org) | Admin (org) | Viewer (ws) | Editor (ws) | Admin (ws) |
|---|---|---|---|---|---|---|
| Read plants | ✅ | ✅ | ✅ | — | — | — |
| Create / update plants | ❌ | ✅ | ✅ | — | — | — |
| Update org settings | ❌ | ❌ | ✅ | — | — | — |
| Create / revoke org invites | ❌ | ❌ | ✅ | — | — | — |
| Update / remove org members | ❌ | ❌ | ✅ | — | — | — |
| Create API keys | ❌ | ❌ | ✅ | — | — | — |
| Read workspace | — | — | — | ✅ | ✅ | ✅ |
| Update / delete workspace | — | — | — | ❌ | ✅ | ✅ |
| Create / remove workspace members | — | — | — | ❌ | ❌ | ✅ |
| Create / revoke workspace invites | — | — | — | ❌ | ❌ | ✅ |
| Create / update / delete projects, EPDs, PCFs | — | — | — | ❌ | ✅ | ✅ |
| Add / remove project team members | — | — | — | ❌ | ❌ | ✅ |
| Force-approve EPD (staff override) | — | — | staff/org-admin only | — | — | — |
Workspace and PCC administration
POST /api/v2/workspaces/ is a cross-resource endpoint — the user acts outside any existing workspace. Creating, updating, or deleting a workspace, and creating, updating, activating, or archiving a PCC (category) within one, all share the same authorization rule (SMA-2177):
- Staff / superusers can do all of these, in every environment, and (for workspace creation) must supply
primary_org_idexplicitly — they're creating on behalf of another org. - Everyone else can do them only outside production, and only as an
admin(OrganizationMembership.role == 'admin') of an organization with thecan_create_workspacescapability enabled. In production, a non-staff caller gets403 forbiddenregardless of role — this is a hard platform restriction, not a permission you can be granted.
For workspace creation specifically, non-staff callers never choose the owning organization: the workspace is always owned by the caller's own qualifying org, resolved server-side, and any client-supplied primary_org_id is ignored.
Practical effect for integrators: in production, a non-staff API caller cannot create a workspace or a PCC at all. Use GET /api/v2/workspaces/ and GET /api/v2/workspaces/{id}/pccs/ to work with the ones SmartEPD has already provisioned for your organization — see the API Quickstart.
Decorator patterns
The backend uses four permission decorators (see smartepd/core/permissions.py):
@require_scopes("organizations:admin", ...)— resolvesorg_idfrom the URL path, checks the caller'sOrganizationMembershiprole in that org.@require_workspace_scopes("workspaces:members", ...)— resolvesworkspace_idfrom the URL path, checksUserWorkspaceMembership.user_role.@require_pcc_scopes(...)/@require_project_scopes(...)— resolve PCC/project → workspace, with fallback toOrgAccessGrantand (for projects)ProjectTeamMember.@require_jwt_auth— blocks API-key authentication (used on auth/impersonation endpoints).
Endpoints that don't have a resource ID in the URL (e.g. POST /api-keys/, POST /workspaces/) use inline role checks via is_org_admin() or is_org_admin_for() selectors; this is documented in each endpoint's docstring.
Workspace-scoped API keys
Every API key is bound to exactly one workspace. The auth layer attaches the key's workspace_id to the request, and:
- Workspace-scoped permission decorators (
@require_workspace_scopes,@require_project_scopes,@require_pcc_scopes,check_workspace_access) reject requests targeting a different workspace by returning 404 (matching the existing cross-org IDOR convention). - List endpoints that span workspaces (e.g.
GET /workspaces/) filter to the key's single workspace, so an integrator never sees data outside the customer they're configured for. - Archived workspace keys stop authenticating immediately — both because the workspace status check runs in the auth layer and because archiving a workspace explicitly revokes all its keys (
workspace_update_status).
To create a workspace-scoped key, the caller must be:
- An admin in some organization (
OrganizationMembership.role == 'admin'), AND - An admin-level workspace member (
UserWorkspaceMembership.user_role == 'admin') on the target workspace.
Each organization may override the per-user key limit (default 3) via Organization.api_key_limit, so multi-customer integrators can hold one key per customer workspace.
Frontend enforcement
The frontend mirrors these rules through a single capability-check layer:
/api/v2/account/mereturnsorg_membershipsandworkspace_membershipsarrays — the full permission context in one request.usePermissions({ workspaceId })hook binds the current user's role/orgTypes to thecheckPermissionfunction insrc/v2/types/permissions.ts.<Can capability="...">component anduseCan(capability, args)hook gate UI elements declaratively.
Frontend gating is a UX convenience — the backend scope check remains authoritative. A user who bypasses the UI will still receive 403 from the API.
CRM access and contact data protection
The in-platform CRM is staff-internal and workspace-agnostic. It sits upstream of the platform — a deal becomes a project when it closes won — so none of the workspace-scoped rules above apply to it.
Who can see the CRM
Access is the is_staff flag, enforced by @require_staff. That is a flat boolean with no further scoping: a staff account reaches every deal, whether or not it owns any of them. Two consequences worth stating plainly:
- The staff flag is granted for unrelated reasons — spec editing, EPD administration, developer tooling — and every holder gains read access to the commercial pipeline.
- A feature flag cannot restrict this.
require_feature_flagunconditionally bypasses for staff and superusers, so a flag placed over a staff-only surface is inert and can never act as a kill switch.
Writes to a deal's commercial figures are narrower than reads. amount, amount_base and exchange_rate may only be changed by the deal's owner or a superuser, enforced in crm.services.deal_assert_money_fields_writable. Reads stay open to all staff so board aggregates keep working. Deals with no owner — expected during the Zoho import, where some Zoho owners map to no platform account — stay writable by any staff user, so the records ops most needs to repair are not locked away.
Contact records and the right to erasure
crm.Contact describes people who never signed up for the platform. They are imported in bulk from Zoho, and some are EU residents. This makes them categorically different from User records, which belong to people who accepted terms.
A GDPR Article 17 ("right to be forgotten") request therefore cannot be satisfied by hiding the row, because the same personal data is retained in four further places:
| Where | Why it survives a soft delete |
|---|---|
crm_contactevent | Contact is tracked append_only, which compiles to a real Postgres trigger blocking updates and deletes. Every historical value of the name, email and phone is kept there. |
crm_notes + crm_crmnoteevent | A note can be written about a contact and its body is unrestricted free text, so names and numbers land there routinely — and its history is append-only too. |
zoho_onboarding_emails | The Zoho mirror stores the recipient address plus from/to inside its raw payload, independently of the contact record. |
accounts.User | If the contact shadows a platform user, the link alone re-identifies the tombstone. |
crm.services.contact_erase reaches all of them. It blanks every personal column in place, keeps the row as a tombstone so deal participation history stays readable, severs the link to any platform user (the user's own account is untouched — they have their own lawful basis), blanks the bodies of notes written about them, deletes both sets of audit rows through an explicit and narrowly-scoped suspension of the append-only triggers, and strips the address out of the Zoho mirror. It then writes a ContactErasureLog row — which, because the audit trail itself has just been destroyed, is the only durable evidence that the request was honoured.
Erasure has to survive the sync, not just happen once. Purging the mirror is a point-in-time rewrite, and the Zoho email sync runs on a six-hourly beat that re-imports the same records — so on its own the address reappears within hours, and because the contact is already flagged erased, re-running the erasure short-circuits and can never clean it again. The erasure log therefore stores a keyed HMAC of the address, and the mirror's upsert path consults it on every write: a suppressed address is dropped at the point it would enter Postgres. It is keyed on SECRET_KEY rather than a plain hash because the space of email addresses is small enough to enumerate, and an unkeyed digest would be reversible — which would put the personal data back into the very row that exists to record its destruction.
Erasure is exposed only as a management command, and dry-run is its default:
python manage.py crm_contact_erase --contact-id <uuid> --actor-email <you> --reason "<DSR ref>"
It is not an API endpoint. The operation is rare, irreversible and legally triggered, so it needs no UI, and requiring production shell access is a tighter gate than any decorator. Add --confirm to execute. There is no undo.
Related pages
- Platform Overview -The entity hierarchy that permissions follow
- Data Model -Technical entity reference including membership models
- Verification Workflow -How verifier access is used in practice