VouchGate is a self-service guest access request portal for Microsoft Entra ID with manager approval. It is a lightweight alternative to Entitlement Management that requires no Governance (Entra ID P2 / Governance) licenses. Internal users request time-boxed guest (B2B) access; the requester's manager approves or denies; on approval VouchGate creates the guest and stamps a lifecycle expiry attribute that is later cleaned up by an external process.
This document is the authoritative functional specification. Implementation of the backend functions and the frontend SPA follows in later steps.
Enable any authorized internal user to request external guest access without a helpdesk ticket or a Governance license, while keeping a human (the requester's manager) in the approval loop and maintaining a complete audit trail.
- Self-service: requesters submit guest access requests through a portal, no ticket required.
- Manager approval: every request is routed to the requester's manager (resolved server-side), with a configurable fallback approver group.
- No Governance license: built only on Azure Static Web Apps, Azure Functions, Azure Table Storage and Microsoft Graph application permissions.
- Time-boxed access: each request carries a requested duration (30 / 90 / 180 / 365 days); the approval expiry is stamped on the guest object.
- External lifecycle cleanup: expiry enforcement is delegated to the
EntraID Guest User LifeCycle Management
project, which reads
extensionAttribute13. - Auditability: every request and decision is persisted with actor, timestamps and source IP.
| Component | Technology | Responsibility |
|---|---|---|
| Frontend | Azure Static Web App (Standard) | Single-file SPA (index.html with embedded CSS + JS), MSAL.js auth (Authorization Code + PKCE) |
| Backend | Azure Functions (Node.js 24, v4 model) | HTTP APIs, request/approval workflow, Graph calls |
| Identity | Entra ID App Registration + Easy Auth | Sign-in, JWT validation before function code runs |
| Graph access | System-assigned Managed Identity | Guest invitation, guest object update, manager lookup, mail |
| State | Azure Table Storage | VouchGateRequests (requests), VouchGateAudit (audit trail) |
| Monitoring | Log Analytics + Application Insights | Telemetry, traces and diagnostics |
Requester ──▶ SPA ──▶ (Easy Auth JWT) ──▶ Function: submitRequest
│
├─▶ resolve manager (Graph /users/{id}/manager)
├─▶ store request (VouchGateRequests)
├─▶ write audit (VouchGateAudit)
└─▶ sendMail to approver (Graph, MAIL_SENDER)
Approver ──▶ notification mail link ──▶ SPA ──▶ (Easy Auth JWT) ──▶ Function: pendingApprovals
│
Approver approves/denies ──▶ Function: decideRequest ──────────────────┤
│
approve ─────────────────┤
├─▶ POST /invitations (Graph, User.Invite.All)
├─▶ set extensionAttribute13 / employeeType / sponsors
├─▶ store decision + write audit
└─▶ sendMail to requester ("sharing possible")
deny ────────────────────┤
├─▶ store decision + write audit
└─▶ sendMail to requester (reason)
- MSAL.js browser library using the Authorization Code flow with PKCE.
- Configuration is injected at deploy time via
authConfig.js(window.VOUCHGATE_CONFIG), generated by the deploy scripts and gitignored. - The SPA acquires an access token for the API scope
api://{clientId}/access_as_userand sends it as a Bearer token to the backend. - No tokens or secrets are persisted server-side.
- Easy Auth (App Service Authentication V2) validates the Entra ID JWT
before any function code runs; unauthenticated requests are rejected with
401automatically (unauthenticatedClientAction: Return401). - The signed-in caller's identity (object ID, UPN, name) is read from the validated token / Easy Auth headers.
- All Microsoft Graph calls use the Function App's system-assigned Managed Identity: there are no stored client secrets.
Assigned to the Managed Identity by the deploy scripts:
| Permission | Why |
|---|---|
User.Invite.All |
Create the guest via POST /invitations |
User.ReadWrite.All |
Read the requester's manager; set extensionAttribute13, employeeType, sponsors on the guest |
GroupMember.Read.All |
Verify fallback approver group membership (checkMemberGroups) |
Mail.Send |
Send all notification mails as MAIL_SENDER |
Delegated User.Read is admin-consented for interactive sign-in.
Least privilege for mail:
Mail.Sendas an application permission allows sending as any mailbox by default. It must be scoped to theMAIL_SENDERmailbox using an Exchange Online Application Access Policy (documented indocs/CONFIGURATION.md).
- An internal user signs in with their Entra ID account.
- The user submits a guest access request with:
- Guest email (required), validated against allowed/blocked domain lists.
- Display name (required).
- Company (optional).
- Justification (required, minimum 10 characters).
- Requested duration (required), one of
30,90,180,365days, capped byMAX_DURATION_DAYS.
- The backend resolves the approver server-side:
- Primary: the requester's manager via Graph
GET /users/{id}/manager. - Fallback: if no manager is set, the request is routed to the configured
approver fallback group (
APPROVER_FALLBACK_GROUP_ID); any member may approve.
- Primary: the requester's manager via Graph
- The request is stored in
VouchGateRequestswith statusPending, an audit record is written, and a notification mail is sent to the approver.
- The approver receives a notification mail containing a link into the portal (the SPA). There are no unauthenticated magic links: the approver must sign in.
- After signing in, the approver sees the list of pending requests assigned to them (as manager, as a member of the fallback group, or, for escalated requests, as a fallback group member; see §5.1).
- The approver approves or denies each request, with an optional comment.
- The decision is validated server-side (the caller must be an authorized
approver for that request) and recorded with a full audit entry that captures
the authorization basis (
approverType:manager/group/fallback-escalation).
To prevent requests stalling on an unavailable manager, VouchGate escalates:
- While a request is
pendingand older thanESCALATION_DAYS(default5), members of the fallback approver group (APPROVER_FALLBACK_GROUP_ID) become additional valid approvers for it, including for manager-routed requests. - This is enforced server-side in both authorization paths: the pending list
(
GET /api/requests?view=pending) shows escalated requests to fallback group members, and the decision endpoint (POST /api/requests/{id}/decision) allows fallback group members to decide them. Eligibility is age-based and re-checked via Microsoft Graph on every call. - A daily timer function
escalationReminder(scheduled byESCALATION_REMINDER_CRON, default"0 0 7 * * *") sends one digest mail to the fallback group listing all pending requests that have passedESCALATION_DAYSand are not yet escalated, then stampsescalatedAton those requests so the digest is never sent twice for the same request. The mail is only stamped after a successful send, so a send failure is retried next run. - Decisions made through escalation are audited with
approverType = fallback-escalation.
A Function performs, in order:
- Create the guest:
POST /invitations(Graph,User.Invite.All) withinvitedUserEmailAddressandinvitedUserDisplayName.sendInvitationMessageisfalse: VouchGate does not send a redemption mail. The guest gains access the first time the requester shares a resource with them (for example, the SharePoint sharing notification), and redemption happens on first access. Existing directory users are matched guests only (userType eq 'Guest'); if the email belongs to an internal member account the approval is refused with422 INTERNAL_USER(and submission is rejected early with400). - On the resulting guest user object, set:
extensionAttribute13= approval expiry date (ISO 8601), computed as approval time + requested duration. This is the value the external lifecycle management reads. Expiry is never shortened (see 6.1.2).employeeType="VouchGate"(marks guests provisioned by this portal).sponsors= the requester (Graph sponsors relationship /$ref).
- Update the request status to
Approved, persist the decision and expiry, and write an audit record. - Send a notification mail to the requester indicating that sharing with the guest is now possible.
Lifecycle guarantee: VouchGate must never set
extensionAttribute15 = "ExcludeFromLCM". Setting it would exclude the guest from the external lifecycle cleanup and defeat the time-boxing.
The approve path is ordered so it is safe to retry after a partial failure:
- Guest creation (
POST /invitations) and the lifecycle attribute patch (extensionAttribute13,employeeType) run before the request status is updated. If either fails, the request stayspendingand can be retried. - Sponsor assignment is best-effort: a failure is logged as a warning and
emitted as an Application Insights event (
VouchGateSponsorAssignmentFailed), but it does not fail the approval. - The request status is written last, only after guest provisioning has completed. A Graph failure mid-flow therefore never leaves a request marked approved without a provisioned guest.
- On a retry, the guest is detected as already existing, so the
approved-existingpath re-applies all attributes (expiry, employeeType and sponsor). This makes the operation self-healing: re-running a partially-failed approval converges to the correct end state.
When the approval targets a guest that already exists (the approved-existing
path), the Function first reads the guest's current
onPremisesExtensionAttributes.extensionAttribute13. If that value is a valid ISO
date later than the newly computed expiry (approval time + requested duration),
the existing later date is kept and the shorter one is discarded. A new
approval may extend access but never reduce it, because another requester
may already rely on the longer period. Concretely:
- Existing expiry later than the requested one: the existing date is preserved (the patch re-stamps the same later value, so the operation stays idempotent).
- Existing expiry earlier than the requested one (or none stored): the newly computed, later expiry is applied, extending access.
The effective expiry that was applied and how it was determined (new,
extended or kept-existing) are recorded in the audit entry (ExpiryDate,
ExpiryDisposition). The requester's approval confirmation mail reflects the
effective expiry and, when a longer existing period was kept, explains that the
requested duration would have shortened it so the existing date was retained.
- Update the request status to
Denied, persist the decision (including the optional reason/comment) and write an audit record. - Send a notification mail to the requester with the denial reason.
State is stored in Azure Table Storage (no database, no Governance license).
Holds one entity per guest access request.
| Field | Notes |
|---|---|
PartitionKey |
Requester object ID (efficient "my requests" queries) |
RowKey |
Request ID (GUID) |
RequesterId / RequesterUpn / RequesterName |
Who requested |
GuestEmail / GuestDisplayName / GuestCompany |
Requested guest |
Justification |
Free text (≥ 10 chars) |
RequestedDurationDays |
30 / 90 / 180 / 365 |
ApproverId / ApproverType |
Resolved approver (manager or group) |
Status |
pending / approved / approved-existing / denied |
DecisionComment |
Optional approver comment |
ExpiryDate |
ISO 8601 expiry (set on approval, mirrors extensionAttribute13) |
GuestObjectId |
Created guest's object ID (set on approval) |
EscalatedAt |
ISO 8601 timestamp the escalation digest was sent (blank until escalated) |
CreatedAt / DecidedAt |
Timestamps |
Append-only audit trail (see §8).
Every request submission and every approval/denial writes an immutable record to
the VouchGateAudit Azure Table.
| Field | Description |
|---|---|
PartitionKey |
Request ID |
RowKey |
Event timestamp + unique suffix |
EventType |
Requested / Approved / ApprovedExisting / Denied |
ActorId / ActorUpn / ActorName |
Who performed the action |
ApproverType |
Authorization basis for a decision (manager / group / fallback-escalation) |
RequesterId |
Original requester |
GuestEmail |
Guest subject of the request |
Comment |
Justification (request) or decision comment |
SourceIp |
Caller IP (from X-Forwarded-For) |
ExpiryDate |
Effective guest access expiry applied on approval (ISO 8601) |
ExpiryDisposition |
How the expiry was determined: new / extended / kept-existing |
Timestamp |
UTC ISO 8601 |
Audit records are retained per the storage account policy; they are never modified or deleted by the application.
All runtime configuration is supplied as Function App application settings
(see backend/local.settings.json.example and infra/modules/functionapp.bicep):
| Setting | Purpose |
|---|---|
TENANT_ID |
Entra ID tenant |
AUTH_CLIENT_ID |
App Registration client ID (Easy Auth audience) |
TABLE_STORAGE_CONNECTION_STRING |
Storage account for both tables |
REQUESTS_TABLE_NAME |
Default VouchGateRequests |
AUDIT_TABLE_NAME |
Default VouchGateAudit |
GRAPH_API_ENDPOINT |
https://graph.microsoft.com |
JUSTIFICATION_MIN_LENGTH |
Minimum justification length (default 10) |
MAX_DURATION_DAYS |
Maximum requestable duration (default 365) |
ESCALATION_DAYS |
Pending age after which fallback group members may also approve (default 5) |
ESCALATION_REMINDER_CRON |
NCRONTAB schedule for the escalation reminder timer (default 0 0 7 * * *) |
APPROVER_FALLBACK_GROUP_ID |
Object ID of the fallback approver group |
ALLOWED_GUEST_DOMAINS |
Comma-separated allow-list (empty = allow all) |
BLOCKED_GUEST_DOMAINS |
Comma-separated block-list (empty = block none) |
MAIL_SENDER |
Shared mailbox used as sender for all notifications |
- All notification mails are sent via Graph
sendMailasMAIL_SENDERusing the Managed Identity (Mail.Sendapplication permission). - Notifications sent:
- To approver: new request awaiting decision, with a link into the portal.
A manager approver is emailed directly. A fallback approver group is notified
by resolving its transitive user members and placing them in BCC of a single
message, so a plain (non-mail-enabled) security group works. If no member has
a mail address the request is still created and a
VouchGateApproverNotificationFailedtelemetry event is emitted. - To requester (approved): sharing with the guest is now possible.
- To requester (denied): request denied, including the reason.
- To fallback group (escalation digest): one mail (member addresses in BCC)
listing all requests pending longer than
ESCALATION_DAYS, sent by the dailyescalationRemindertimer.
- To approver: new request awaiting decision, with a link into the portal.
A manager approver is emailed directly. A fallback approver group is notified
by resolving its transitive user members and placing them in BCC of a single
message, so a plain (non-mail-enabled) security group works. If no member has
a mail address the request is still created and a
Mail.Sendmust be scoped to theMAIL_SENDERmailbox with an Exchange Online Application Access Policy (documented indocs/CONFIGURATION.md).
- Requester view: guest access request form and the requester's own request history with status.
- Approver view: list of pending requests assigned to the signed-in approver, with approve / deny actions and an optional comment field.
- Single-file SPA:
index.htmlwith embedded CSS and JS. - Light/dark theme with a toggle in the top bar; the default honors
prefers-color-scheme; the choice is applied viadata-theme="dark". - Client-side validation mirrors backend rules (email format, justification length, duration options) but is never authoritative.
- All colors, fonts, radii and shadows are defined as CSS custom properties in
frontend/css/theme.css(the EphemGate branding system, re-headed for VouchGate), including a full[data-theme="dark"]block. - The SPA must consume only these custom properties: no color, font, radius
or shadow value is hardcoded in
index.html.
- Supported via Azure Static Web Apps (Standard tier); the optional
customDomainBicep parameter provisions the custom domain resource.
- Node.js 24, Azure Functions v4 programming model, Linux B1 App Service Plan.
- Application Insights initialized at startup (
src/index.js). - Shared libraries in
src/lib/:auth.js(identity/JWT),graph.js(Managed Identity Graph client),telemetry.js,audit.js,requests.js(request store),approver.js(approver resolution + escalation),mailTemplates.js.
| Method & Route | Description |
|---|---|
POST /api/requests |
Submit a new guest access request |
GET /api/requests?view=mine |
List the signed-in requester's own requests |
GET /api/requests?view=pending |
List pending requests the signed-in approver may decide (incl. escalated) |
GET /api/my-approver |
Return the resolved approver's display name for the request form |
POST /api/requests/{id}/decision |
Approve or deny a request (optional comment) |
escalationReminder (timer) |
Daily digest of over-age pending requests to the fallback group |
Both list views and the decision endpoint enforce authorization server-side:
the approver is independently re-resolved (manager object-ID match, fallback
group membership via checkMemberGroups, or age-based escalation); client role
claims are never trusted. The decision endpoint is idempotent, a request that is
not pending returns 409, and concurrent decisions are guarded with an ETag
(If-Match) update.
Also referenced in lib/approver.js (approver resolution) and lib/requests.js
(request store).
- Resource group (subscription-scope deployment).
- Log Analytics Workspace + Application Insights (
monitoring). - Storage account with
VouchGateRequestsandVouchGateAudittables (storage). - Linux B1 App Service Plan (
appServicePlan). - Static Web App (Standard) (
staticwebapp). - Function App with system-assigned Managed Identity and Easy Auth (
functionapp).
projectName, location, swaLocation, resourceGroupName, customDomain,
authClientId, approverFallbackGroupId, allowedGuestDomains,
blockedGuestDomains, maxDurationDays, mailSender.
All resources are prefixed with projectName: <project>-plan, <project>-law,
<project>-ai, <project>-swa, <project>-func, and a globally-unique storage
account name derived from projectName.
- Easy Auth enforced on all backend endpoints (
Return401for anonymous). - No stored secrets, Managed Identity for all Graph access.
- Least-privilege Graph application permissions (§3.3).
Mail.Sendscoped toMAIL_SENDERvia Exchange Online Application Access Policy.- Server-side authorization: approvers can only decide requests actually assigned to them; requesters can only see their own requests.
- Recommended access controls before go-live: Entra ID assignment enforcement (assign a dedicated security group) and Conditional Access (MFA, location, block legacy auth).
- On approval VouchGate stamps
extensionAttribute13with the ISO 8601 expiry. - The external
EntraID Guest User LifeCycle Management
reads
extensionAttribute13and performs expiry cleanup (notification, disable, delete) outside of VouchGate. - VouchGate never sets
extensionAttribute15 = "ExcludeFromLCM".
- Expiry enforcement / cleanup (handled by the external lifecycle project).
- Multi-stage or multi-approver workflows beyond manager + fallback group.
- Access reviews and recertification (use Entra ID Governance if required).
- Automatic re-invitation or renewal of expired guests.