Skip to content

Secret management ​

Moʻolelo treats every sensitive configuration value — connector OAuth secrets, refresh tokens, GitHub PATs, identity-source credentials — as a reference to a value held in an external key vault, not as a literal stored in Postgres or pasted into the admin UI.

The platform supports four vault backends out of the box. Operators register one or more secret providers through the admin UI; every reference string then resolves through them at use site, with a small in-process cache so repeated lookups don't hammer the vault.

Supported backends ​

BackendAuth chain
HashiCorp Vault (KV v1 + v2)Kubernetes SA auth → AppRole → token (auto-detected)
Azure Key VaultDefaultAzureCredential (Workload Identity → Managed Identity → env SP → az CLI)
AWS Secrets ManagerAWS SDK default chain (IRSA → instance profile → env → shared file)
Google Cloud Secret ManagerApplication Default Credentials (Workload Identity → SA key → env)

Every backend uses its cloud SDK's default credential chain. On managed Kubernetes — AKS, EKS, GKE, OpenShift on a public cloud — pods authenticate via workload identity with zero credential in the environment. The pod's identity is the bootstrap credential.

On pure Docker, Docker Swarm, and unmanaged VMs the operator falls back to env-var credentials. That's the only "bootstrap secret" required — and it's the platform's only chicken-and-egg secret regardless of how many connector + identity providers are configured.

Reference format ​

<provider-id>://<path>[#field]

Examples:

vault-prod://secret/data/connectors/github#token
azkv-prod://moolelo-secrets/confluence-refresh-token
aws-prod://prod/moolelo/sharepoint-secret#client_secret
gcp-prod://projects/moolelo/secrets/sharepoint-secret/versions/latest

The <provider-id> is the id the operator assigned when registering the provider in the admin UI. The <path> is backend-specific (Vault KV path; Azure secret name [optionally with /<version>]; AWS SecretId or ARN; GCP short-id or full resource name). The optional #field selects one key when the stored value is JSON-shaped — useful for storing OAuth bundles ({"client_id": "...", "client_secret": "...", "refresh_token": "..."}) as a single secret.

Literal vs reference auto-detection ​

Every sensitive field auto-detects whether you supplied a literal value or a vault reference. Backwards compat is preserved — existing literal credentials continue to work unchanged.

The disambiguation rule is strict to avoid false positives:

A value is treated as a reference only when both of the following hold:

  1. It has the syntactic shape <scheme>://<path> where <scheme> is lowercase alphanumerics + - / _.
  2. The <scheme> is a currently-registered provider id in /secret-providers.

This means plain URLs like postgres://user:pw@db/moolelo, https://login.microsoftonline.com/…, or pulsar://broker:6650 pass through as literals even though they technically match the syntactic shape — because their scheme isn't a registered provider id. You can paste these into env vars or admin-UI fields and they work exactly as before.

Coverage ​

✓ = supports both literal and vault reference shapes.

Sensitive fieldResolution
Connector OAuth client_secret / refresh_token / PATs (GitHub, Confluence, Jira, SharePoint)✓
Identity-collector OAuth + tokens (Atlassian, Entra, GitHub)✓
Webhook signing secrets (WEBHOOK_INGRESS_*_SECRET)✓ at boot (rotation requires pod restart)
Webserver admin session signer (MOOLELO_ADMIN_SESSION_SECRET)✓ at boot
OIDC client secret (MOOLELO_OIDC_CLIENT_SECRET)✓ at boot
OpenFGA pre-shared token (WEBSERVER_OPENFGA_TOKEN, QUERY_COORDINATOR_OPENFGA_TOKEN, ACL_PROJECTOR_OPENFGA_TOKEN)✓ at boot
NebulaGraph passwords (QUERY_COORDINATOR_NEBULAGRAPH_PASSWORD, …_SUPERSESSION_NEBULAGRAPH_PASSWORD)✓ at boot
Postgres DSN (WEBSERVER_POSTGRES_DSN, CONNECTOR_PG_DSN, IDENTITY_COLLECTOR_PG_DSN, ACL_PROJECTOR_PG_DSN, QUERY_COORDINATOR_SUPERSESSION_POSTGRES_URL)✓ at boot

For each of these, paste either:

  • A literal: postgres://moolelo:hunter2@db/moolelo, ghp_xxx…, etc.
  • A reference: vault-prod://secret/data/db-creds#dsn, azkv-prod://moolelo-secrets/db-dsn, etc.

Env vars that MUST stay literal (the bootstrap floor) ​

Even when every other secret is sourced from a vault, a small set of env vars must remain literal. These are the bootstrap credentials needed BEFORE the secret-provider machinery is reachable; making them reference-able would be a chicken-and-egg.

Env varServiceWhy it can't be a reference
MOOLELO_ADMIN_BOOTSTRAP_TOKENwebserverGates the admin surface (including /secret-providers) BEFORE any provider can be configured. The operator needs this to log in the first time and add the first provider.
WEBSERVER_POSTGRES_DSNwebserverThe master reads secret_providers rows from its OWN Postgres. The DSN therefore must be literal — the resolver can't help fetch the DSN before Postgres is connected. (Other services' DSNs reach the master over the control plane, so they CAN be references.)
MOOLELO_LICENSE_SOURCEwebserverDefines where the license envelope lives (Postgres / Vault / Azure KV / file). The resolver can't read it before the license is verified at boot, so the source URL is consumed before the resolver is constructed.
MOOLELO_CONTROL_PLANE_URLevery data-plane serviceTells the binary where the master is. It IS the bootstrap pointer — every other resolution depends on it.
Pulsar / Vespa / object-storage endpoints (*_PULSAR_URL, *_VESPA_BASE_URL, S3 endpoints)variousThese are non-secret endpoint URLs. Auth to these services is via mTLS / IAM / workload identity (set out-of-band via Helm values), not via the URL string.
Cloud-provider bootstrap creds for the secret-provider's OWN auth (e.g. AZURE_CLIENT_ID, AWS_ACCESS_KEY_ID, VAULT_TOKEN)data-plane pods, only when workload identity isn't availableThe one ‘seed' secret that gets the provider talking to its backend. On managed-K8s with workload identity these aren't needed.

That's it. Everything else — every OAuth secret, every refresh token, every PAT, every database password, every signing key — can be a vault reference on top of the master-Postgres + bootstrap-token + control-plane-URL floor.

Provider's own auth — workload identity vs env-var ​

The provider's own credential (the one it uses to talk to Vault / KV / Secrets Manager / Secret Manager) is the only secret that can't be sourced from itself. The platform handles this by leaning on cloud workload identity wherever it exists:

DeploymentHashiCorp VaultAzure Key VaultAWS Secrets ManagerGCP Secret Manager
AKSK8s auth methodAKS Workload IdentityOIDC federation (rare)OIDC federation (rare)
EKSK8s auth or AWS IAM authOIDC federation (rare)EKS IRSAOIDC federation (rare)
GKEK8s auth methodOIDC federation (rare)OIDC federation (rare)GKE Workload Identity
OpenShift on cloudK8s auth methodWorkload Identity via OIDCIRSA via OIDCWorkload Identity via OIDC
Managed container hosts (ACI / ECS / Cloud Run / App Runner)AppRole / tokenManaged IdentityInstance / Task roleRuntime SA
Docker / Swarm / unmanaged VMsAppRole or token (env)Service Principal (env)Access key / secret (env)SA-key JSON file or env

The Helm overlays in deploy/values/with-aks-workload-identity.yaml, with-eks-irsa.yaml, and with-gke-workload-identity.yaml annotate the data-plane pods' ServiceAccounts with the right cloud-specific labels + annotations. See Configuring secret providers for the full setup walk-through.

Caching + rotation ​

Every resolved value is cached in-process for 5 minutes by default (secrets.DefaultCacheTTL). Rotations propagate within 5 minutes without a service restart.

Provider-config changes (a new provider added, an endpoint URL changed) propagate within 5 minutes as well — every data-plane service polls /api/internal/secret-config on a refresh ticker. When the wire payload's fingerprint changes the in-memory Registry is rebuilt atomically; in-flight requests using the old Registry finish unaffected.

For faster rotation: lower MOOLELO_SECRETS_CACHE_TTL (env var). At the trade-off of more frequent vault calls.

For immediate rotation: rolling-restart the affected data-plane Deployments. The Registry is rebuilt from scratch + the cache is empty, so the next request resolves through the vault.

Audit + observability ​

Every Resolve call records a structured log line with provider_id, path, and outcome (cached / fetched / not-found / unreachable). The resolved value is never logged. Operators can index these in Loki and alert on resolve-failure rates per provider to catch outages or misconfigurations before they propagate to user-facing failures.

Failure semantics ​

ConditionOutcome
Reference uses an unknown provider-idResolve returns ErrUnknownProvider; the connector / identity backfill fails at the source-system auth call with a clear "credentials not resolvable" message
Provider is unreachable (network / IAM denied)Resolve returns the provider's error wrapped; same downstream surface
Reference is malformedErrInvalidReference at decode time
Field selector #field not present in resolved secretErrFieldMissing
Plain literal (no :// prefix)Pass-through unchanged — backwards-compat with existing configs

The fail-modes are deliberately fail-closed at use site: a misconfigured reference will not silently allow a connector to start with bogus credentials. The source system rejects the call, the connector logs the resolver chain, and the operator fixes it from the admin UI.

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