Appearance
Users & Access
Companion pages: Permission Model — the mechanism behind Permission Sets, assignments, granting, and evaluation. Initial Permission Sets — the concrete Permission Sets EM3 ships with, migrated from EM2's roles.
Business Context
Business-Level Definition
Users & Access covers how people are identified and granted permission to act within EM3, across one or more client organisations (tenants).
Every person who works with EM3 — whether a Porsenna platform operator or a client-side user — is represented by a single, platform-wide user identity. That identity comes into existence when an administrator invites the person, specifying the tenant (or platform scope) and how they are being granted access. Authenticating that person (proving who they are) is always delegated to an external identity provider; the invited person completes their access the first time they successfully sign in there. From that point on, EM3's own responsibility is to recognise the authenticated person and to control what they may then do.
What a user may do is expressed as one or more Permission Sets — named, reusable bundles of permissions (e.g. an administrator, a manager, a worker, a read-only reviewer) — reaching them through one of two mechanisms, depending on who they are:
- Tenant-side users normally get access by being a member of a Group — a named, tenant-scoped collection of users (e.g. Admins, Accountants) that holds one or more Permission Set grants. A user belongs to at most one group per tenant, but can belong to a different group in a different tenant. Direct, per-user Permission Set grants remain available for a tenant user as a minor, edge-case path outside any group.
- Porsenna platform users are always granted directly, one Permission Set assignment at a time — including a Porsenna user who needs scoped access across several tenants (e.g. a regional manager), which is simply one direct grant per tenant, each independently restrictable. Porsenna users never use groups.
Whichever mechanism grants it, a user's effective access in a tenant is the union of everything every source grants — every group they belong to there, plus any direct grants — and holds unrestricted access wherever an all-access Permission Set is involved. Mechanically, group-based access is materialized into the same place a direct grant lives — a group membership or a group's own Permission Set grant changing propagates, synchronously, into the affected user's permissionSetUserAssignment rows — so evaluating a user's access is always a single read of that one table, never a live join through their groups; see Permission Model — Group access propagation.
A restriction can further narrow what a specific grant covers (e.g. limiting a tenant user's access to a subset of buildings) without changing which permissions are granted — restrictions live on the individual holder's membership or direct assignment, not on the Permission Set itself, so two people with the same role can be restricted differently. building (tenant-side) and clientGroup (Porsenna-side) are the two restriction dimensions; see restriction collection for the full shape, including why combining more than one dimension on the same holder does not arise today.
A user's membership in a tenant (which tenants they can see and switch between) always follows from holding access there — through a group or a direct grant — and is never granted on its own.
A user's access can be withdrawn by suspending them. Suspension only blocks the person from signing in — it does not remove any group membership or Permission Set assignment they held, so restoring a user gives back exactly the access they had before.
A Platform-managed "template" mechanism for pre-populating a new tenant with a standard set of groups was considered and deliberately deferred: a global (shared) Permission Set was rejected as the mechanism for a template's default grants, since editing it would change behaviour for every tenant using it, and cloning Permission Set content per tenant would need a second, parallel templating concept not worth the added design surface without stakeholder validation. Every group in this analysis is created directly, one tenant at a time — not from a template.
Licence and module entitlement — which modules a tenant's subscription includes — is a related but separate concern, covered under Client Management rather than here.
Requirements Definition
- Every person who accesses EM3 is identifiable as one unique user, regardless of how many tenants they access.
- A user comes into existence only through an invitation that names the person (email) and grants them access to at least one thing (a group membership or a direct Permission Set grant), for a tenant or at platform level — a user cannot be created without also being granted access to something.
- Tenant-side access is normally granted through Group membership; a tenant-scoped group holds one or more Permission Set grants, additive across every group it holds.
- A user belongs to at most one group per tenant; a group belongs to exactly one tenant for its entire lifetime.
- Porsenna platform users are always granted access directly, never through a group; a single Porsenna user can hold independent, separately restrictable grants across multiple tenants.
- A direct Permission Set grant remains available to a tenant-side user as a minor, edge-case alternative to group membership.
- A grant (direct, or via group membership) can carry a restriction narrowing what it covers, without changing which permissions are granted.
- Platform-level access is represented independently of any tenant's Permission Sets.
- A user's membership in a tenant is a consequence of holding access there (via group or direct grant), not a separately granted fact.
- A user's access can be withdrawn by suspending them, without discarding their group memberships or Permission Set assignments.
Acceptance Criteria
- A user authenticates once and is recognised, without re-registering, in every tenant they subsequently access.
- An invited person becomes an active user the first time they successfully sign in through the identity provider with the identity the invitation was addressed to.
- A user's effective permissions within a tenant are exactly the union of what every group they belong to there grants, plus every direct grant they hold there, narrowed by any restriction recorded on the specific membership or grant — with any explicit deny taking precedence over an allow for the same permission.
- A user holding an all-access Permission Set (platform or tenant administrator), through any source, is granted unrestricted access within that scope.
- A user can belong to at most one group within a given tenant at any time.
- A suspended user cannot sign in; restoring them gives back their prior group memberships, direct Permission Set assignments, and tenant memberships unchanged.
Loom Link
N/A
Technical Context
User Stories / Use Cases
- 🚫 Not in MVP. As an administrator, I create a group for a tenant and grant it one or more Permission Sets.
- As an administrator, I add a tenant user to a group, optionally restricting their access within it (e.g. to a subset of buildings).
- As an administrator, I invite a person by email into a tenant (adding them to a group, or granting access directly) or at platform level, in the same action as creating their user record.
- As a person who has just been invited, I am recognised as an active user the first time I sign in through the identity provider.
- As an administrator, I grant a Porsenna user direct, independently restrictable access to one or more tenants.
- As an administrator, I assign an additional direct Permission Set to an existing user, for a tenant they already belong to or a new one.
- As an administrator, I suspend a user to withdraw their access without losing their group memberships or assignments, and can restore them later to give it back.
- As a user belonging to more than one tenant, I see and switch between the tenants I am a member of.
UI/UX Design
No full UI/UX design has been built yet for this feature; this analysis defines behaviour and data, not final screens. Interactive wireframes exist for both apps, sketching the screens this analysis implies: design/platform-wireframes.html (Users, Groups, Roles — platform administration app) and design/tenant-wireframes.html (Users — tenant app). A few concrete UI constraints are already settled, though, and are recorded here so they are not lost before screens are designed:
- Creating a Porsenna user and creating a tenant user happens in the same UI, but it must be clearly, visibly distinguished which kind is being created.
- A person's name is entered and edited as separate
firstName/lastNamefields, stored separately (stakeholder requirement) — every form and table keeps them separate. A combineddisplayName(lastName + " " + firstName) is computed only for overview-style listings that show one name column; it is never stored — see user. - A person's phone number is optional everywhere it's collected — never a required field.
- Building selection during user/membership creation cannot be submitted empty, but the data model treats "no restriction" as valid (and different from "every building that exists today"). The UI resolves this with a distinct "Select All" control, kept visibly and functionally separate from the individual building checkboxes — see restriction collection for why the two are not interchangeable.
Per-record history wireframe. The tenant-level "Uživatelé" screen (design/tenant-wireframes.html) carries a header "Historie" button on its Detail scene, opening that user's own change history (see Audit Log — Per-Record History component for the shared destination view). Recreated with the button and modal added on top: design/html/uzivatele-detail.html. This is a tenant-level screen, not a Client tab.
Functional Requirements
A user is uniquely identified at the platform level, independent of any tenant.
A group belongs to exactly one tenant, set at creation and never changed.
A user can hold at most one live group membership per tenant (enforced at the data level); the same user may belong to a different group in a different tenant, and may simultaneously hold a direct assignment in any tenant.
A group's Permission Set grants are additive: a group holding several Permission Sets grants the combined access of all of them to every one of its members.
Inviting a person creates their user record in
invitedstatus together with at least one access grant (a group membership or a direct assignment) in the same action; an invitation cannot be created without a destination. If the invited email already belongs to a live user (typically a member of a different tenant), no second user record is created — the new grant is added to that existing user instead (find-or-grant; seePOST /v1/usersfor the full behaviour).MVP scope: there is no group-management UI — every group is pre-seeded, 🚫 never created, edited, or deleted through the product — and no "Roles section" for editing which Permission Set a group holds (🚫 a group's Permission Set assignment is fixed at seed time). The Invite screen adds a person to an existing seeded group (or grants Porsenna-side direct access, including a second tenant's access to someone who already has an account elsewhere, via find-or-grant on
POST /v1/users, above); a separate admin action grants or revokes an existing user's direct Permission Set assignment. Permission Sets themselves are seeded and rule-editable, but 🚫 not creatable or deletable through the product.An invited user's record is created from the invitation's email and display name, before the person has ever signed in and before their identity-provider identity is known; the identity-provider identifier is optional until first sign-in. Their identity-provider identity is attached the first time they sign in with an identity whose email matches the invitation, at which point their status becomes
active.A Permission Set is either global (seeded once, assignable in any tenant, or at platform level) or scoped to one specific tenant.
A restriction recorded on a groupMembership or a permissionSetUserAssignment narrows what that specific holder's grant covers; it never widens it, and it never changes which Permission Sets are granted.
Two Permission Sets — one at platform level, one at tenant level — are all-access and are not evaluated rule-by-rule.
A rule's
denyalways overrides anallowfor the same permission.A user's tenant membership is maintained automatically from their group memberships and direct assignments; it is not set independently.
Suspending a user sets their status to
suspendedand blocks sign-in; it does not remove or alter any of their group memberships or Permission Set assignments. Restoring a user (setting status back toactive) gives back their prior access exactly as it was, with no re-assignment needed.Porsenna users are never members of a group; every Porsenna-side grant is a direct permissionSetUserAssignment.
The tenant-level Uživatelé screen's Detail scene carries a "Historie" entry point onto that user's own change history (
user), reached in place — the same audit trail as Audit Log. See UI/UX Design above for the wireframe and review status.
Granting and Evaluation Mechanics
A fixed catalog of permission codes (e.g. platform.permission-sets.read) is the unit every Permission Set rule is written against; each rule pairs a code with an effect (allow/deny) and a scope ("breadth" — all vs a narrower value). Granting is bounded two ways: a granter can only edit (🚫 minting a new Permission Set is not in MVP) or grant a Permission Set at or below their own highest held level (role hierarchy), and a restriction placed on a grant can only narrow, never exceed, the restriction ceiling already in force for the granter. Every effective grant — direct or via group — is materialized onto permissionSetUserAssignment, so evaluating a request is always a single read of that one table (an explicit deny beats an allow for the same permission), never a live join through group membership. See Permission Model for the full mechanism: the capability catalog, scope binding, group access propagation, granting rules, rule precedence, evaluation, and the diagnostic endpoint.
Internationalization & Localization
N/A — this feature introduces no user-facing display text of its own beyond what the existing Translations generic already covers (e.g. Permission Set names, group names, status labels).
Non-Functional Requirements
N/A — beyond the identity-provider dependency stated in Risk Assessment, no additional non-functional requirement is specific to this feature.
Performance
Permission evaluation runs on every authorized request, so it must not add meaningful latency: it reads a single materialized table (permissionSetUserAssignment, already reflecting every direct grant and every synchronously-propagated group grant — see Group access propagation) rather than joining through groupMembership or permissionSetGroupAssignment at evaluation time, and is planned as a cache read — see Caching below.
Transactional Operations
- Inviting a user (creating the
userrow and its first grant — agroupMembershiporpermissionSetUserAssignment) commits as one atomic unit — a partially-created invitation (a user with no grant, or vice versa) must never be observable. - Granting or revoking a direct Permission Set assignment, adding or removing a group membership, and the derived
tenantUserupdate either triggers, commit as one atomic unit. - Suspending or restoring a user is a single-row update; no other table participates.
Processes & Related Systems / Components
- Identity provider (external): owns authentication; EM3 recognises an already-authenticated person and attaches their identity-provider identifier on first sign-in.
- Client Management (related, not part of this feature): governs licence and module entitlement per tenant — a separate concept that happens to be checked alongside tenant identity, but is not a Permission Set and is not covered here.
- EM2 (predecessor product): its seeded roles and permissions were extracted as background research for defining EM3's Groups and Permission Sets — see Reference: EM2 Roles & Permissions. A factual extraction, not a design decision; nothing there is assumed to carry over to EM3 as-is.
Diagrams & Models
sequenceDiagram
participant Admin
participant EM3
participant Person
participant IdP as Identity Provider
Admin->>EM3: Invite person (email, tenant/platform, group or direct permission set(s))
EM3->>EM3: Create user (status=invited, oid=null) + groupMembership or permissionSetUserAssignment
Person->>IdP: Sign in
IdP-->>Person: Authenticated
Person->>EM3: Present identity (matches invited email)
EM3->>EM3: Attach oid, set status=active
EM3-->>Person: Access granted per group membership / direct assignment(s)
stateDiagram-v2
[*] --> invited: Invited (with a group membership or direct grant)
invited --> active: First successful sign-in
active --> suspended: Suspend
suspended --> active: Restore
API Analysis
See API-A: inviting a user, listing/reading users, suspending/restoring a user, group CRUD (🚫 create/delete not in MVP — listing only), group membership management (🚫 listing not in MVP — add/remove only), listing/reading/editing Permission Sets (🚫 create/delete not in MVP), granting/revoking/listing direct Permission Set assignments, group Permission Set assignments (🚫 not in MVP), the self-service tenant listing, the self-service capabilities list, and diagnostics. Jira stories still to be created are tracked in stories-breakdown.md.
Domain Model (ER diagram) & Data Attribute Table
erDiagram
USER ||--o{ PERMISSION_SET_USER_ASSIGNMENT : "granted (direct or propagated)"
USER ||--o{ GROUP_MEMBERSHIP : "member of"
GROUP ||--o{ GROUP_MEMBERSHIP : "has member"
GROUP ||--o{ PERMISSION_SET_GROUP_ASSIGNMENT : "granted"
GROUP ||--o{ PERMISSION_SET_USER_ASSIGNMENT : "propagates via sourceGroupId"
PERMISSION_SET ||--o{ PERMISSION_SET_USER_ASSIGNMENT : "granted via"
PERMISSION_SET ||--o{ PERMISSION_SET_GROUP_ASSIGNMENT : "granted via"
USER ||--o{ TENANT_USER : "belongs to"
PERMISSION_SET_USER_ASSIGNMENT }o--o| TENANT_USER : "derives"
GROUP_MEMBERSHIP }o--o| TENANT_USER : "derives"
- user — the platform-wide identity of a person.
- group — a named, tenant-scoped collection of users, used to grant tenant-side access as a unit.
- groupMembership — a user's membership in a group; carries any restriction narrowing that member's access; propagates into permissionSetUserAssignment rather than being read directly at evaluation time.
- permissionSet — a named, reusable bundle of permission rules.
- permissionSetUserAssignment — the single table evaluation reads: a Permission Set held by one user, for one tenant (or at platform level), either as a genuine direct grant (
sourceGroupIdnull) or materialized from group membership (sourceGroupIdset) by synchronous propagation. - permissionSetGroupAssignment — grants one Permission Set to one group; the normal authoring mechanism for tenant users, propagated into permissionSetUserAssignment rather than read directly at evaluation time.
- tenantUser — a derived record of which tenants a user belongs to, maintained automatically from permissionSetUserAssignment and groupMembership.
Inviting a user always creates the user record and its first grant (a groupMembership or a permissionSetUserAssignment) together — the two never exist independently of each other.
Data
Global Permission Sets are seeded, not user-created — representative shape:
sql
INSERT INTO platform.permission_sets (key, name, visibility, rules, created_by, updated_by) VALUES
('platform-admin', 'Platform Administrator', 'global', '[]', 'system:seed', 'system:seed'),
('tenant-admin', 'Tenant Administrator', 'global', '[]', 'system:seed', 'system:seed'),
('manager', 'Manager', 'global',
'[{"permissionCode":"tenant.buildings.read","effect":"allow","scope":"all"}]',
'system:seed', 'system:seed');platform-admin and tenant-admin are all-access (see Business Context) and carry no rules of their own — their rows exist for identity and assignment, not for rule evaluation.
Test Data
- Platform fixtures:
make seed-superadmin— seedssuperadmin@(active,platform-adminset granted directly, plustenant-adminin every tenant granted directly) anddev@(active, deliberately given no group membership and no direct assignment, to exercise the all-permissions-denied /403path). - E2E tenant:
apps/e2e/setup/seed-e2e-tenant.ts— search for the tenant-admin Permission Set binding it creates for its test identity-provider user (currently a direct binding; update this reference once the fixture is migrated to a seeded group, if that happens).
Logging
N/A — no feature-specific log events are defined; user-action tracking for this feature is covered under Auditing, Reporting & Measurement.
Monitoring
N/A — no feature-specific monitoring or alerting exists today.
Caching
A user's tenant membership is cached for up to 60 seconds; a revoked or newly-granted membership can take up to that long to take effect. This bound is accepted as the current behaviour — see Risk Assessment.
Decided (design; not yet built), simplified by Group access propagation: permission evaluation itself is a cache read in the common case — one cache entry per (actorId, tenantId) holding the actor's full set of decided capability codes for that tenant, plus a separate platform-wide entry (tenantId = null) for global grants, mirroring the permissionSetUserAssignment nullable-tenantId shape used everywhere else. Decided: Redis, with a 15-minute TTL as the floor; explicit invalidation on write keeps it correct in between, on four triggers:
- Any permissionSetUserAssignment row created, updated, or removed — direct or propagated — invalidates the affected actor for that row's tenant (or every tenant, if platform-wide). This one trigger covers every group-membership or group-grant change too, since those changes reach this table synchronously before their own transaction commits.
- A permissionSet's
rulesor active flag changing invalidates every actor currently holding it. Backed byPATCH /v1/permission-sets/{permissionSetId}. - The actor is suspended or deleted (see
POST /v1/users/{userId}/suspend) — invalidates every cache entry for that actor, all tenants and the platform-wide entry alike. - A tenant leaving
active(TenantStatus) invalidates every cache entry scoped to that tenant, for every actor.
Backward Compatibility and Migration
N/A — this feature governs invitations and grants going forward; it requires no migration of existing data.
Legal Context
N/A — no contract, licence or regulatory requirement specific to this feature has been identified.
Cybersecurity Considerations
- Authentication is delegated entirely to the identity provider; EM3 never stores or handles a password.
- A user's identity is uniquely anchored to their identity-provider account; the system prevents two different identity-provider accounts from being recognised as the same user through a shared email address.
- An invitation must only be completed by someone who authenticates with an identity-provider identity whose email matches the invitation — matching by email at first sign-in is a trust boundary and must not silently attach to a different identity.
- A group's tenant is immutable once set, and a group Permission Set assignment's tenant is always trigger-derived from the live group rather than independently writable — this is a deliberate control against a cross-tenant grant ever being created by mistake or by a bypassed write path (e.g. a bulk import). The evaluation path never trusts the derived column alone; it resolves tenant scope from the live group.
- No audit logging exists today for cross-tenant reads of a person's tenant memberships. Who may read cross-tenant is fully governed by this model (an ordinary platform-level grant); whether each such read gets logged is a separate, undesigned concern belonging to audit/access logging generally (the
em3-346-audit-logfeature), not to Users & Access.
Risk Assessment
- EM3 depends on the identity provider for authentication; an outage there prevents anyone from signing in. Accepted as a platform-level dependency.
- Caching of tenant membership means a revoked access can continue to work for up to 60 seconds after revocation. This bound is accepted as the current behaviour.
- An invited user's record exists (with an email and display name) before their identity-provider identity is known; until they complete their first sign-in, that record cannot be verified against a real authenticated identity. This window is inherent to an invite-before-signup model and is bounded by the invitation itself carrying no access on its own — the group membership or direct grant only takes effect once the identity is attached.
- A group/Permission-Set "template" mechanism for pre-populating a new tenant with common groups has been proposed but not validated with stakeholders and is deliberately out of scope for now — see the note under Business-Level Definition above.
Auditing, Reporting & Measurement
Changes to a set's rules are audited on permissionSet; changes to who holds a set are audited on permissionSetUserAssignment and permissionSetGroupAssignment, each filing the assignee as the audit entry's subject (per-person or per-group full grant history as one indexed read). tenantId is a genuine stored attribute of each assignment row, not inferred per query, so filing the audit entry under that tenant is sound. A propagated permissionSetUserAssignment row is audited the same way a direct one is — see Group access propagation for how its createdBy/updatedBy is attributed to the actor who made the triggering group-side change, so both the group's history and the affected user's history record it.