Data Model¶
Before this page
Assumes you have read The words you need first. Brand new? Start at The words you need first.
Identity Atlas uses a unified data model (v3.1) that stores all authorization entities — from any source system — in a consistent structure backed by PostgreSQL with trigger-based audit history.
Core Design Principles¶
Four principles drive the data model design:
Universal
Any authorization source maps to the same tables. Entra ID groups, SAP roles, Omada business roles, and custom CSV imports all become Resources and Principals in the same schema. No source-specific tables.
Audited
All core tables are tracked by a shared _history audit table populated by PostgreSQL triggers. Every insert, update, and delete is recorded as a JSONB snapshot, giving you a complete change history for any entity. The trigger skips unchanged rows during re-syncs to avoid bloating the audit log.
Core + JSON
Frequently queried attributes (displayName, resourceType, department) are real SQL columns with indexes. System-specific fields that vary by source live in an extendedAttributes JSONB column. This gives you index performance on hot paths without a rigid, source-specific schema.
Unified business roles
Business roles are not stored in a separate table. They are Resources with resourceType = 'BusinessRole'. Their assignments are ResourceAssignments flagged governed = true (with a Direct assignmentType). Their resource grants are ResourceRelationships with relationshipType = 'Contains'. The result is a single set of views, risk scores, and queries that apply to all resource types equally.
Conceptual Hierarchy¶
The model is organized around real people, not system accounts:
Identities (real persons — the governance anchor)
└─ Principals (accounts in source systems, via IdentityMembers)
└─ ResourceAssignments (what access each account holds)
└─ Resources (what is being accessed)
Contexts (organizational/structural trees — synced, generated, or manual)
└─ ContextMembers (which entities belong to each context)
└─ Identities / Principals / Resources / Systems (by memberType)
Systems (technical sync root — each Principal, Resource, and synced Context belongs to one System)
Why Identity is the root: A real person (Identity) may have multiple accounts (Principals) across different source systems. Organizational context — department, team, cost center — belongs to the person, not to each individual account, so it is modelled as Contexts whose members are Identities.
Membership is many-to-many (since v6): Entities do not carry a contextId column. Membership lives in the explicit ContextMembers join table (contextId + memberType + memberId), so a single Identity, Principal, or Resource can belong to many contexts at once — an HR department and a location and a tag — without picking a "primary" one. (Before the v6 Contexts redesign in migration 018_context_redesign.sql, each entity had a single contextId FK; those columns were dropped.)
Why each Context has a targetType: A context groups exactly one kind of entity — Identity, Principal, Resource, or System. An HR department tree targets Identities; an Active Directory OU tree targets Principals; a resource-classification tree targets Resources. They coexist in the same Contexts table, discriminated by targetType + contextType.
Three variants: Every context is synced (ingested from a source system), generated (emitted by a context-algorithm plugin — org-chart derivation, clustering, orphan detection), or manual (analyst-curated, including tags). See context-redesign.
Why Systems are the sync root: At ingestion time, every Principal, Resource, and synced Context must belong to a System. This enables multi-tenant and multi-system deployments without ambiguity.
Entity Relationship Diagram¶
The diagram below is generated from the core ontology — the formal, version-controlled description of these tables. It shows each core table's identifier, foreign keys and type columns; every column is listed in the Core Model Reference.
erDiagram
ChangeRequests {
uuid id PK
uuid basisClaimId FK
string changeKind "EntityCreate, EntityDelete, RelationshipAdd, RelationshipRemove, AttributeUpdate"
uuid contextId FK
uuid memberId FK
uuid overrideId FK
string requestStatus "open, resolved, withdrawn"
int targetSystemId FK
}
ContextClaims {
uuid id PK
string claimKind "description, property, role, roleHistory, membership, alias, activity, expertise, duty"
string claimStatus "proposed, confirmed, edited, rejected"
uuid contextId FK
uuid identityId FK
uuid interviewId FK
uuid objectContextId FK
uuid principalId FK
string proposedBy "model, analyst"
string relevance "high, low"
string roleSuggestion "Accountable, Responsible"
string subjectKind "Context, Person"
string subjectMatch "interviewee, key, name, fuzzy, firstName"
}
ContextInterviews {
uuid id PK
uuid contextId FK
string extractionStatus "pending, running, done, failed, notConfigured"
uuid identityId FK
uuid principalId FK
}
ContextMemberOverrides {
uuid id PK
uuid claimId FK
uuid contextId FK
uuid memberId FK
}
ContextMembers {
string addedBy "sync, algorithm, analyst, sourceOwner"
uuid contextId FK
uuid memberId FK
string memberType "Identity, Resource, Principal, System"
}
ContextRelationships {
uuid id PK
string addedBy "sync, algorithm, analyst, sourceOwner"
uuid contextId FK
uuid relatedContextId FK
string relationshipType "SameUnitAs"
}
ContextRoles {
uuid id PK
string addedBy "sync, algorithm, analyst, sourceOwner"
uuid contextId FK
uuid identityId FK
uuid principalId FK
string role "Accountable, Responsible"
uuid sourceClaimId FK
}
Contexts {
uuid id PK
string contextCategory "OrganizationalUnit, LogicalApplication, Tag, Segment"
string contextType "open vocabulary"
string displayName
uuid parentContextId FK
int scopeSystemId FK
string targetType "Identity, Resource, Principal, System"
string variant "synced, generated, manual"
}
Identities {
uuid id PK
string displayName
uuid managerIdentityId FK
uuid primaryPrincipalId FK
}
IdentityMembers {
string displayName
uuid identityId FK
uuid principalId FK
}
PrincipalRelationships {
uuid principalId FK
uuid relatedPrincipalId FK
string relationshipType "Owner, Sponsor"
int systemId FK
}
Principals {
uuid id PK
string displayName
uuid managerId FK
string principalType "User, ServicePrincipal, ManagedIdentity, WorkloadIdentity, AIAgent, ExternalUser, SharedMailbox"
int systemId FK
}
ResourceAssignments {
string assignmentType "Direct, Indirect, Eligible"
string effect "allow, deny, eligible, notset"
uuid identityId FK
string origin "Automatic, Requested, Discovered"
uuid principalId FK
string propagationScope "self, descendants, selfAndDescendants"
uuid resourceId FK
int systemId FK
}
ResourceRelationships {
uuid childResourceId FK
uuid parentResourceId FK
string relationshipType "Contains, GrantsAccessTo, HasAppRole, DelegatesScope, HasApplicationPermission, HasOwnership, HasAppOwnership"
int systemId FK
}
Resources {
uuid id PK
string displayName
string resourceType "open vocabulary"
int systemId FK
}
Systems {
int id PK
int directorySystemId FK
string displayName
}
ContextClaims |o--o{ ChangeRequests : "basisClaimId"
Contexts |o--o{ ChangeRequests : "contextId"
Identities |o--o{ ChangeRequests : "memberId"
Resources |o--o{ ChangeRequests : "memberId"
Principals |o--o{ ChangeRequests : "memberId"
Systems |o--o{ ChangeRequests : "memberId"
ContextMemberOverrides |o--o{ ChangeRequests : "overrideId"
Systems |o--o{ ChangeRequests : "targetSystemId"
Contexts |o--o{ ContextClaims : "contextId"
Identities |o--o{ ContextClaims : "identityId"
ContextInterviews |o--o{ ContextClaims : "interviewId"
Contexts |o--o{ ContextClaims : "objectContextId"
Principals |o--o{ ContextClaims : "principalId"
Contexts |o--o{ ContextInterviews : "contextId"
Identities |o--o{ ContextInterviews : "identityId"
Principals |o--o{ ContextInterviews : "principalId"
ContextClaims |o--o{ ContextMemberOverrides : "claimId"
Contexts |o--o{ ContextMemberOverrides : "contextId"
Identities |o--o{ ContextMemberOverrides : "memberId"
Resources |o--o{ ContextMemberOverrides : "memberId"
Principals |o--o{ ContextMemberOverrides : "memberId"
Systems |o--o{ ContextMemberOverrides : "memberId"
Contexts |o--o{ ContextMembers : "contextId"
Identities |o--o{ ContextMembers : "memberId"
Resources |o--o{ ContextMembers : "memberId"
Principals |o--o{ ContextMembers : "memberId"
Systems |o--o{ ContextMembers : "memberId"
Contexts |o--o{ ContextRelationships : "contextId"
Contexts |o--o{ ContextRelationships : "relatedContextId"
Contexts |o--o{ ContextRoles : "contextId"
Identities |o--o{ ContextRoles : "identityId"
Principals |o--o{ ContextRoles : "principalId"
ContextClaims |o--o{ ContextRoles : "sourceClaimId"
Contexts |o--o{ Contexts : "parentContextId"
Systems |o--o{ Contexts : "scopeSystemId"
Identities |o--o{ Identities : "managerIdentityId"
Principals |o--o{ Identities : "primaryPrincipalId"
Identities |o--o{ IdentityMembers : "identityId"
Principals |o--o{ IdentityMembers : "principalId"
Principals |o--o{ PrincipalRelationships : "principalId"
Principals |o--o{ PrincipalRelationships : "relatedPrincipalId"
Systems |o--o{ PrincipalRelationships : "systemId"
Principals |o--o{ Principals : "managerId"
Systems |o--o{ Principals : "systemId"
Identities |o--o{ ResourceAssignments : "identityId"
Principals |o--o{ ResourceAssignments : "principalId"
Resources |o--o{ ResourceAssignments : "resourceId"
Systems |o--o{ ResourceAssignments : "systemId"
Resources |o--o{ ResourceRelationships : "childResourceId"
Resources |o--o{ ResourceRelationships : "parentResourceId"
Systems |o--o{ ResourceRelationships : "systemId"
Systems |o--o{ Resources : "systemId"
Systems |o--o{ Systems : "directorySystemId"
Two satellite tables hang off the core model. They are not part of the ontology yet (see Core Ontology):
erDiagram
PrincipalActivity {
guid principalId FK
guid resourceId FK
string activityType
datetime lastSignInDateTime
int signInCount
}
RiskScores {
guid entityId
string entityType
int riskScore
string riskTier
string riskClassifierMatches
}
Principals ||--o{ PrincipalActivity : "has activity"
Resources |o--o{ PrincipalActivity : "accessed in"
Principals ||--o| RiskScores : "entityType=Principal"
Resources ||--o| RiskScores : "entityType=Resource"
Table Reference¶
Identities¶
Real persons aggregated across multiple accounts and source systems. An Identity is the result of account linking: one human may have an Entra ID user, a service account, and a privileged admin account — all linked to one Identity record. Links come from a crawler's IdentityFilter (authoritative, score-less) or from the deterministic Account Linking engine (orphan accounts attached with a confidence score).
Organizational context (department, team) belongs to the person, not to their individual accounts, so an Identity's context memberships live in ContextMembers (memberType='Identity') — an Identity can belong to several contexts at once.
| Property | Value |
|---|---|
| Primary Key | id UUID |
| Audit history | Yes (via _history trigger) |
| Created by | Migration 001_core_schema.sql |
Key columns: displayName, email, employeeId, department, jobTitle, primaryPrincipalId, managerIdentityId.
Account-linking columns: isHrAnchored, hrAccountId, accountCount, linkSignals (JSONB), linkConfidence, linkedAt. These were renamed from correlationSignals / correlationConfidence / correlatedAt in migration 030_account_linking.sql.
orphanStatus is retired
Orphan-ness is no longer modelled as a property on the identity/account. The deterministic Account Linking run emits a generated "Orphaned Accounts" Context (via the orphaned-accounts plugin) containing every Principal not linked to any Identity, sub-grouped by detected account type. The Identities.orphanStatus column remains in the schema for backward compatibility but is no longer the source of truth.
IdentityMembers¶
The join table between Identities and Principals. One identity links to one or more principals, potentially across different source systems.
| Property | Value |
|---|---|
| Primary Key | Composite: identityId + principalId |
| Audit history | No |
| Created by | Migration 001_core_schema.sql |
Per-account link columns (renamed in migration 030_account_linking.sql): linkConfidence, linkSignals, accountType, accountTypePattern, plus analystOverride (confirmed / rejected / moved). Crawler-sourced links carry no linkConfidence (NULL); Account Linking writes scored links and honours analystOverride on every re-run.
Contexts¶
Organizational and structural groupings — synced from a source system, generated by a plugin, or manually curated. A Context can represent an HR department, an AD organizational unit, an Entra ID administrative unit, a resource classification category, a tag, a cluster, or any other grouping. The contextType column discriminates between them, targetType declares which kind of entity they group, and scopeSystemId scopes synced contexts to a source system.
Multiple independent context trees: Each (scopeSystemId, targetType, contextType) combination forms its own tree. These trees are independent — an HR department tree, an AD OU tree, and a resource classification tree all coexist in the same table without interfering.
| Source System | contextType | Linked To | Example |
|---|---|---|---|
| HR system (CSV) | OrgUnit |
Identities | Finance > Accounts Payable > Invoice Processing |
| Active Directory | OrganisationalUnit |
Principals | corp.local > Users > Amsterdam > Admins (generated from the accounts' DNs by the ad-ou-from-dn plugin) |
| Entra ID | AdministrativeUnit |
Principals | AU-Netherlands, AU-Germany |
| Entra ID | Department |
Principals | Generated from Principals.department by the department-from-principal plugin |
| Resource mgmt | Classification |
Resources | Confidential > Finance Data > Payment Systems |
| Custom | Any string | Any | Fully extensible |
Membership lives in ContextMembers, not on the entity: Since the v6 redesign there is no contextId column on Identities, Principals, or Resources. Instead the ContextMembers join table records membership as (contextId, memberType, memberId), so any entity can belong to multiple contexts at once:
- An Identity can be in an HR department context and a location context and a tag.
- A Principal can be in an AD OU context and an Entra administrative-unit context.
- A Resource can be in a classification context and a generated cluster.
ContextMembers.addedBy records provenance — sync (from a crawler), algorithm (a context plugin), or analyst (manual).
Three variants (the variant column): synced (ingested from a source system), generated (emitted by a context-algorithm plugin — org-chart derivation, resource clustering, orphan detection), or manual (analyst-curated, including tags). targetType (Identity / Resource / Principal / System) declares which kind of entity a context groups.
Context and policy-driven access: When an assignment is driven by an Identity's context (e.g., "all Finance employees get access to SharePoint Finance"), the governing rule is captured in AssignmentPolicies.policyConditions as a JSON condition referencing the context. The assignment row in ResourceAssignments records the result; the policy row records the rule. This keeps assignments clean while the "why" remains auditable through the policy chain.
| Property | Value |
|---|---|
| Primary Key | Contexts.id UUID; ContextMembers keyed on (contextId, memberId) |
| Audit history | Yes for Contexts (via _history trigger); no for ContextMembers |
| Created by | Migration 018_context_redesign.sql (replaced the legacy OrgUnits / per-entity contextId shape) |
Key columns (Contexts): variant, targetType, contextType, displayName, parentContextId (self-referencing for hierarchy), scopeSystemId, sourceAlgorithmId / sourceRunId (provenance for generated contexts), directMemberCount / totalMemberCount.
contextType values: Department, Division, CostCenter, Team, Office, Project, Location, OrgUnit, AdministrativeUnit, Classification, Tag, or any custom string.
Tags can target Identities
Tags are stored as manual Contexts (contextType='Tag'). As of migration 031_tags_identity_targettype.sql, tags support targetType='Identity' in addition to Principal and Resource, so an analyst can tag an identity directly. The GraphTags backward-compat view surfaces targetType='Identity' rows as entityType='identity'.
Systems¶
Represents a connected authorization source. Every resource and principal is owned by exactly one system.
| Property | Value |
|---|---|
| Primary Key | id SERIAL |
| Audit history | Yes (via _history trigger) |
| Created by | Migration 001_core_schema.sql |
Key columns: displayName, systemType (e.g. EntraID, Omada, SAP, CSV), enabled.
Resources¶
Any permission-granting entity: Entra ID groups, directory roles, application roles, business roles, SharePoint sites, Azure RBAC roles, or any custom type. The resourceType column discriminates between them.
| Property | Value |
|---|---|
| Primary Key | id UUID |
| Audit history | Yes (via _history trigger) |
| Created by | Migration 001_core_schema.sql |
Key columns: displayName, resourceType, systemId, extendedAttributes (JSON), catalogId, isHidden, riskScore. Classification/grouping is via ContextMembers (memberType='Resource'), not a column.
ResourceAssignments¶
Captures who has access to what, and how. The assignmentType column distinguishes direct, indirect and PIM-eligible access; the separate governed flag marks governance-driven (IGA) assignments.
Every row targets either a specific account (principalId) or a person (identityId) — exactly one must be set (XOR CHECK constraint). Use the principal endpoint for account-level assignments (e.g. "this AD account is in this group") and the identity endpoint for person-level assignments (e.g. "this person has been given this access" from an IGA system).
| Property | Value |
|---|---|
| Uniqueness | Two partial unique indexes: (resourceId, principalId, assignmentType, governed) WHERE principalId IS NOT NULL; (resourceId, identityId, assignmentType, governed) WHERE identityId IS NOT NULL |
| Audit history | Yes (via _history trigger) |
| Created by | Migration 001_core_schema.sql |
| Modified by | Migration 036_resource_assignments_identity_support.sql, 047_governed_assignment_flag.sql, 082_assignment_origin.sql |
Key columns: assignmentType, governed, principalId (nullable), identityId (nullable), systemId, principalType, origin, originDetail, complianceState, policyId, state, assignmentStatus, expirationDateTime, extendedAttributes (JSONB).
ResourceRelationships¶
Resource-to-resource links. Contains links a business role to the resources it grants (and a scope to the scopes inside it); GrantsAccessTo expresses that holding one resource implies access to another. The remaining types (HasAppRole, DelegatesScope, HasApplicationPermission, HasOwnership, HasAppOwnership) link an application or owned resource to its synthetic child resources.
| Property | Value |
|---|---|
| Primary Key | Composite: parentResourceId + childResourceId + relationshipType |
| Audit history | Yes (via _history trigger) |
| Created by | Migration 001_core_schema.sql |
Key columns: relationshipType, roleName, roleOriginSystem.
Principals¶
All identity types from any system. The principalType column distinguishes human accounts from service principals, managed identities, AI agents, and more.
| Property | Value |
|---|---|
| Primary Key | id UUID |
| Audit history | Yes (via _history trigger) |
| Created by | Migration 001_core_schema.sql |
Key columns: displayName, principalType, systemId, managerId, extendedAttributes (JSON), riskScore. Source-system org placement (e.g. AD OU) is via ContextMembers (memberType='Principal'), not a column.
managerId and managerIdentityId point at different tables
managerId always names a Principal — the manager's account; it is self-referential on Principals. managerIdentityId always names an Identity — the manager as a person — and appears on Identities. Identities has no managerId at all, and Contexts has neither column (the ingest still accepts manager fields on a context, but they never resolve — see Core Ontology, finding 2).
Crawlers do not fill either directly: they cannot know an internal id. They send managerExternalId (resolved against the system's principals) or managerIdentityExternalId (resolved against its identities), and the ingest derives the id the manager's own row is keyed by — see app/api/src/ingest/normalization.js. A reference that resolves to nobody is cleared and counted at the end of the load (app/api/src/ingest/managerLinks.js) rather than stored, because managerId IS NOT NULL is what "has a manager" means everywhere else.
PrincipalActivity¶
High-frequency activity signals: sign-ins, per-app usage, AI agent invocations. This table is intentionally not tracked by audit triggers. See Activity Data below for the reason.
| Property | Value |
|---|---|
| Primary Key | Composite: principalId + resourceId + activityType |
| Audit history | No (upsert-based) |
| Created by | Migration 017_principal_activity.sql |
Key columns: activityType, lastSignInDateTime, lastNonInteractiveSignInDateTime, lastSuccessfulSignInDateTime, lastFailedSignInDateTime, signInCount.
RiskScores¶
Risk assessment results for any entity type (Principal, Resource, Identity, Context). Written by Invoke-FGRiskScoring and updated by analyst overrides.
| Property | Value |
|---|---|
| Primary Key | Composite: entityId + entityType |
| Audit history | No |
| Created by | Migration 004_risk_scoring.sql |
Key columns: riskScore, riskTier, riskDirectScore, riskMembershipScore, riskStructuralScore, riskPropagatedScore, riskClassifierMatches (JSON), riskOverride, riskOverrideReason.
Risk scoring also uses several supporting tables for inputs (org context, classifiers, correlation rules) and outputs (resource clusters). See Risk Scoring Data Model for the full picture.
principalType Values¶
The principalType column on the Principals table uses these standard values across all sync and scoring functions.
| Value | What it covers | Populated by |
|---|---|---|
User |
Interactive human user accounts | Entra ID crawler, CSV crawler |
ServicePrincipal |
App registration service principals | Entra ID crawler |
ManagedIdentity |
Azure resource-attached identities (system or user-assigned) | Entra ID crawler |
WorkloadIdentity |
Federated credential identities (GitHub Actions, AKS workloads) | CSV, SQL, SCIM and midPoint mappings only — the Entra ID crawler does not classify accounts this way |
AIAgent |
AI agents: Copilot Studio, Azure OpenAI, custom bots | Entra ID crawler (tag/name auto-detection), CSV crawler |
ExternalUser |
Guest / B2B accounts from another tenant | Omada crawler, CSV crawler |
SharedMailbox |
Shared mailboxes and room/equipment accounts | CSV, SQL, SCIM and midPoint mappings only — no built-in crawler classifies accounts this way |
Risk scoring behavior by principalType
User principals receive the full set of stale sign-in, never-signed-in, and guest-account checks. Non-human types (ServicePrincipal, ManagedIdentity, WorkloadIdentity, AIAgent) receive structural signals only — no stale sign-in checks. All types participate in direct classifier matching, membership analysis, and risk propagation.
resourceType Values¶
The resourceType column on the Resources table is a free-form string. The values below are produced by the built-in crawlers.
| Value | Crawler | What it represents |
|---|---|---|
Group |
Entra ID | Security group or Microsoft 365 group |
EntraDirectoryRole |
Entra ID | One resource per Entra directory role (id = roleDefinitionId); held Direct (active) or Eligible (PIM). |
GroupOwnership |
Entra ID | Owners of a group — one resource per owned group, linked to it via relationshipType='HasOwnership'; each owner is a Direct assignment. |
ResourceOwnership |
SQL connector | Owners of a resource loaded by the SQL connector, linked via HasOwnership; the owned resource's type is in extendedAttributes.ownedResourceType. |
Application |
Entra ID | Enterprise application / service principal. Parent of AppRole and DelegatedPermission — does not grant access on its own. |
AppRole |
Entra ID | One synthetic resource per (Application, appRoleId). Linked to its parent via relationshipType='HasAppRole'. |
DelegatedPermission |
Entra ID | One synthetic resource per (clientSP, targetAPI, OAuth2 scope). Linked to its parent via relationshipType='DelegatesScope'. |
ApplicationPermission |
Entra ID | One synthetic resource per (clientSP, targetAPI, appRole) — the app-only (admin-consented) permission a service principal holds on another API (e.g. Mail.Read on Microsoft Graph). The app-only sibling of DelegatedPermission; linked to its parent client app via relationshipType='HasApplicationPermission'. |
ServicePrincipalOwnership |
Entra ID | Owners of an enterprise-app service principal — one resource per owned app, linked to its Application via relationshipType='HasAppOwnership'. |
ApplicationOwnership |
Entra ID | Owners of an app registration (who can add a credential and authenticate as the app), matched to the app's SP by appId and linked via HasAppOwnership. |
BusinessRole |
Entra ID, Omada, MidPoint | Entra ID access package; Omada business role; MidPoint role type. Contains child resources via relationshipType='Contains'; assigned via a Direct membership flagged governed=true. |
Entitlement |
MidPoint | AD group or other entitlement synced as a MidPoint shadow (kind=entitlement). |
Resource |
Omada | Omada resource. |
Service |
MidPoint | MidPoint service type. |
| Custom | Any crawler | Any string — fully extensible for any authorization source. |
Extending resourceType
You can use any string value for custom source systems. The model does not enforce an enum — resourceType is TEXT. Use a consistent naming convention such as SystemPrefix_TypeName (e.g., SAP_Role, Pathlock_Permission) so queries and views remain readable.
assignmentType Values¶
The assignmentType column on ResourceAssignments describes how a principal holds the access. Only three values are accepted — ingest rejects anything else (see app/api/src/ingest/assignmentTypes.guard.test.js):
| Value | Meaning |
|---|---|
Direct |
Directly held — a group/role membership, a directly-assigned app role, an OAuth2 grant, or ownership (modelled as a Direct membership on a GroupOwnership resource) |
Indirect |
Inherited through a nested resource (e.g. an app role held via group membership) |
Eligible |
PIM-eligible membership — granted but not yet activated |
Everything that used to be its own assignmentType is now modelled differently — the source detail is carried elsewhere, not in assignmentType:
- Ownership → a
Directmembership on aGroupOwnershipresource (not anOwnertype). - Governance → the
governedboolean flag on the assignment, with the business role / access package flaggedgovernanceResource(not aGovernedtype). - Source-attribute detail (former
OAuth2Grant,AppRole,AppRoleViaGroup,DirectoryRole,DirectoryRoleEligible) → collapses toDirect/Indirect/Eligible, withresourceTypecarrying the source detail.
CSV and custom crawlers follow the same rule — map onto one of the three accepted values (they can't invent their own assignmentType).
Source System Mapping¶
The same tables (Resources, Principals, ResourceAssignments) absorb data from any source. The crawler and the resourceType / assignmentType values are the only things that differ.
| Source System | Crawler | resourceType | principalType | assignmentType |
|---|---|---|---|---|
| Entra ID groups | Entra ID | Group |
User |
Direct / Eligible |
| Entra ID group ownership | Entra ID | GroupOwnership |
User |
Direct |
| Entra ID enterprise apps | Entra ID | Application |
— | — (parent only) |
| Entra ID app roles | Entra ID | AppRole |
User / ServicePrincipal |
Direct / Indirect |
| Entra ID OAuth2 grants | Entra ID | DelegatedPermission |
User |
Direct |
| Entra ID app permissions | Entra ID | ApplicationPermission |
ServicePrincipal / ManagedIdentity / AIAgent |
Direct |
| Entra ID access packages | Entra ID | BusinessRole |
User |
Direct (governed) |
| Omada business roles | Omada | BusinessRole |
User |
Direct (governed) |
| Omada resources | Omada | Resource |
User |
Direct (governed) |
| MidPoint roles | MidPoint | BusinessRole |
User |
Direct (governed) |
| MidPoint entitlements (AD groups etc.) | MidPoint | Entitlement |
User |
Direct |
| MidPoint service types | MidPoint | Service |
User |
Direct (governed) |
| Any system | CSV / custom crawler | Any string | Any | Direct / Indirect / Eligible |
Activity Data (PrincipalActivity)¶
PrincipalActivity is physically separated from Principals by design, even though it describes the same entities.
Why not store activity in Principals?
Principals is tracked by the _history audit trigger, which records a JSONB snapshot every time a row changes. Sign-in timestamps change daily — sometimes hourly — for active accounts. Storing lastSignInDateTime on Principals would generate enormous audit history for data that is not meaningful to review. A user's last sign-in two minutes ago is not a material change that anyone needs to audit.
What PrincipalActivity does instead:
Each row stores the latest known activity per (principalId, resourceId, activityType) combination. Sync functions upsert into this table, overwriting the previous value in place. No history is retained — the table is always a current snapshot.
Activity types:
| activityType | Source | Meaning |
|---|---|---|
SignIn |
Entra ID audit log | Most recent interactive or non-interactive sign-in |
AppSignIn |
Entra ID audit log | Last sign-in to a specific application (resourceId = app) |
Invocation |
AI platform telemetry | Last invocation of an AI agent |
Use in risk scoring:
The risk engine queries PrincipalActivity to detect:
- Stale accounts —
Userprincipals with noSignInactivity in 90+ days - Ghost app roles —
AppRoleassignments where the user has never signed in to the app - Active high-privilege usage — principals actively using sensitive resources (reduces risk score)
- AI agent dormancy —
AIAgentprincipals with no recentInvocationactivity