Skip to content

Identity catalog ​

For Moʻolelo's authorization model to work, the platform needs to know who the users are — not just what records exist. The identity catalog is the per-tenant store of users, groups, and memberships, harvested continuously from the identity providers your organization already uses.

The catalog is what makes "Moʻolelo never grants access the source system wouldn't grant" mechanically true. When a query arrives, the authorization layer asks: who is this caller, what groups are they in, what does each source's authority chain say about this record? The identity catalog is the answer to the first two questions.

What it harvests ​

ProviderWhat's harvestedAuth
atlassianConfluence + Jira users, groups, group membershipsOAuth 2.0 (3LO refresh-token grant)
entraMicrosoft Entra ID (Azure AD) users, groups, membershipsOAuth 2.0 (client-credentials)
githubGitHub organization members, teams, team membersPersonal Access Token (read:org)

Each provider is independent — you connect the ones your organization uses, the ones you don't are simply absent. A typical deployment turns on Entra (because the company uses Microsoft 365) plus the provider for whichever content sources are connected.

Canonical identity ​

The same person often appears in multiple identity providers — Atlassian emits an accountId, Entra emits an id, GitHub emits a numeric id. Moʻolelo merges these into a single canonical person ID so an ACL rule that grants access to "alice@example.com" matches her record from every provider.

The default canonical ID is the person's lowercased corporate email. This is the most reliable cross-provider key: every IdP ultimately ties back to the same email address, even when the in-IdP identifier shape differs. Operators with a different canonical ID (employee number, SCIM-emitted UUID) override the resolver via deployment overlay.

For users an IdP doesn't expose an email for — typically GitHub contributors who keep their email private — operators can add an explicit alias via the admin API. The identity_aliases table maps (tenant, provider, provider_id) → canonical_email and the resolver falls back to it when no email is present in the source record.

Residency-relevant attributes ​

When the privacy substrate is enabled, Moʻolelo's residency layer needs to know which region each user's personal data physically resides in. The identity catalog harvests the attributes that drive this decision:

Canonical attributeSource
country_codeEntra country, customer-supplied override
data_residency_regionCustomer-supplied (custom-extension attribute)
legal_entityCustomer-supplied (custom-extension attribute)
usage_locationEntra usageLocation
departmentEntra, Atlassian profile
company_nameEntra companyName
office_locationEntra officeLocation
employee_idEntra employeeId

These land in the tenant_user_attributes Postgres table. Operators inspect them via the admin API:

bash
GET /api/admin/tenants/{tenant}/users/{canonical-email}/attributes

This is the single source of truth the residency-service reads. If a user's residency-relevant attributes change in the upstream IdP, the collector picks up the change on the next webhook delivery (or the next poll cycle for IdPs that don't publish change webhooks) and the residency-service sees the new values immediately.

Operating modes ​

The identity collector supports the same four modes as the content connectors:

  • Backfill — paginate every listing endpoint once. Used during onboarding to catch up on history.
  • Webhook — consume change notifications from the IdP's organization-level webhooks. Real-time updates.
  • Poll — re-walk the listing endpoints on a configurable interval. Used when an IdP doesn't expose change webhooks; default is hourly.
  • Webhook + reconcile — both webhook and poll together, so a missed delivery is recovered on the next reconcile pass.

A typical onboarding sequence is backfill → webhook+reconcile.

"Disabled if not configured" applies here too: if you haven't created a tenant_identity_configs row for a provider, the collector for that provider stays loaded but inactive. Adding the row brings it online without restarting the binary.

Setting up the Atlassian identity source ​

The Atlassian identity source uses the same OAuth 2.0 (3LO) app as the Confluence + Jira content connectors, with additional scopes on top. If you already have the connectors wired, you only need to extend the existing app — there is no second app to create.

In the Atlassian developer console, open your app and add these scopes on top of whatever the connectors already require:

Granular scopes (under "Permissions"):

  • read:user:confluence + read:group:confluence — Confluence-side user + group reads.
  • read:user:jira + read:group:jira — Jira-side user + group reads.

Classic scope (under "Classic scopes → Jira platform REST API"):

  • read:jira-user — required. The identity collector walks /rest/api/3/users/search, /rest/api/3/group/bulk, and /rest/api/3/group?expand=users — all on the classic Jira REST surface. The classic name is the only one that authorizes them; the granular variants don't cover them. Without it consent succeeds, the access token includes the scope you DID grant, but every directory call 401s with "scope does not match".

Always required:

  • offline_access — same reason as the connectors. The Atlassian refresh-token rotation (every token exchange invalidates the previous refresh) is handled transparently by the collector and persisted back to tenant_identity_configs via the webserver.

Configure the source ​

From the admin UI, open Identity sources → New identity source, pick Atlassian, and complete the same consent flow you ran for the connectors. Behind the scenes that writes a tenant_identity_configs row equivalent to:

sql
INSERT INTO tenant_identity_configs (
  tenant_id, source, instance, mode, resources, raw_config, updated_at
) VALUES (
  'default',
  'atlassian',
  'default',
  'backfill',
  '[]'::jsonb,
  jsonb_build_object(
    'cloud_id',      '<your cloud id>',
    'site_url',      'https://acme.atlassian.net',
    'client_id',     '<from your OAuth app>',
    'client_secret', '<from your OAuth app>',
    'refresh_token', '<from the consent flow>'
  ),
  now()
);

A single atlassian row covers BOTH Jira and Confluence users — the two products share the same Atlassian Cloud user catalog, so one backfill populates the aliases the ACL projector uses to resolve principals on Confluence AND Jira ACL events.

Hidden-email users ​

Atlassian's privacy controls let a user hide their email from API consumers even when the consenting account has full directory access. Such users come back from /users/search with an empty emailAddress, so the email-based canonicalizer skips them and they won't appear in the People inspector or get a tuple in OpenFGA when they show up on an ACL. There's no client-side workaround for the hidden email itself; if you need those users in the catalog you have to add an explicit alias row mapping their accountId → canonical email via the admin API.

What the catalog feeds ​

The identity catalog produces three streams that downstream services consume:

  1. Per-tenant Pulsar topic <region>.identity.<source>.events — change events the ACL projector reads to maintain the OpenFGA store in sync with reality.
  2. tenant_user_attributes Postgres table — residency attributes the residency-service reads when deciding which regional vault a personal field should live in.
  3. identity_aliases Postgres table — the operator-curated provider-id → canonical mapping that handles the long tail of users whose IdP record doesn't expose an email.

These three together are what make the ACL evaluator work without leaking data across users or regions.

Moʻolelo — self-hosted federated enterprise knowledge platform.