# ADR-0045: Isolated Named Credential Profiles for Multi-Tenant Configuration

## Status

✅ Accepted (2026-07-07)

## Context

omni-dev talks to several credentialed backends — Atlassian, Datadog,
Snowflake, and the Claude/AI providers — each configured through environment
variables with a fallback to an `env` map in `~/.omni-dev/settings.json`
(process environment first, then the file). One machine, however, frequently
serves **more than one tenant**: a work Atlassian site *and* a personal one, two
Datadog orgs, several Snowflake accounts. A single `env` map cannot hold two
values for `ATLASSIAN_API_TOKEN` at once.

Issues #1089, #1110, and #1116 added **AWS-CLI-style named credential
profiles**: a `profiles` map alongside the base `env`, with the active one
selected per invocation by a global `--profile` flag or the `OMNI_DEV_PROFILE`
environment variable — exactly the model of the AWS CLI's `--profile` /
`AWS_PROFILE`.

The design's central choice is deliberately *contrary* to how the rest of
omni-dev layers configuration, which is why it warrants an ADR:

- [ADR-0005](adr-0005.md) resolves on-disk config files (`commit-guidelines.md`,
  `scopes.yaml`) by **walking up** the directory tree and taking the nearest —
  a fallback chain.
- [ADR-0022](adr-0022.md) builds the model catalog by **deep-merging** user and
  project YAML *over* an embedded base, so a partial override inherits every
  field it does not restate.

Both instincts — walk-up and merge — are about *filling in* from a lower layer.
For credentials that instinct is a hazard: if an active tenant's profile is
missing a key and resolution silently fell back to the base `env`, a command
would run against the **wrong tenant's** credential without any error. For auth
material that is a confused-tenant failure, not a convenience. So the profile
subsystem had to choose isolation over merging, and that inversion is the
decision recorded here.

## Decision

Add a `profiles: HashMap<String, Profile>` field to `Settings`
([`src/utils/settings.rs`](../../src/utils/settings.rs)) beside the existing
base `env` map, where each `Profile` carries its own `env` map. The active
profile is selected per invocation and threaded through both the read and write
sides of the credential store.

### Isolation, not merge — an active profile *replaces* the base `env`

The load-bearing decision: when a profile is active, its `env` map is the **sole**
settings-layer source; the base `env` is **not** consulted. Active-profile `env`
*xor* base `env` — never both. `Settings::resolve_with_source` encodes this
directly:

1. The raw process environment, if the key is set there — this layer always
   wins.
2. Then, *branching on whether a profile is active*: the active profile's `env`
   map **or** (when no profile is active) the base `env` map — never a fall-through
   from one to the other.

```rust
if let Some(value) = raw.var(key) {
    return Some((value, EnvValueSource::ProcessEnv));
}
match active {
    Some(name) => self.profiles.get(name)
        .and_then(|p| p.env.get(key).cloned()) // profile env ONLY
        .map(|value| (value, EnvValueSource::SettingsProfile(name.to_string()))),
    None => self.env.get(key).cloned()          // base env ONLY
        .map(|value| (value, EnvValueSource::SettingsEnv)),
}
```

A key absent from the active profile therefore **fails loud** (resolves to
`None`, and the backend reports the missing credential) rather than silently
reusing a base-`env` default against the wrong tenant. This is the deliberate
safety property; the ergonomic cost of it is accepted below.

### Precedence and selection

Resolution precedence, highest to lowest:

1. **Process environment** — an exported shell/CI variable always wins, so secret
   injection is unaffected regardless of the active profile.
2. **The `--profile` flag**, which beats **`OMNI_DEV_PROFILE`**. The flag is a
   *global* flag propagated by `Cli::propagate_global_flags`, which exports its
   value into `PROFILE_ENV_VAR` so downstream settings readers discover the
   active profile from one place. `active_profile_from` reads that variable from
   the **raw** process environment only — never through the profile fallback,
   which would be circular.
3. **The active profile's `env`** (or, with no profile active, the base `env`).

Selecting an **unknown** profile is a **hard error at the CLI boundary**, not a
silent fallback. `Cli::validate_active_profile` runs `Settings::validate_profile`
once before command dispatch; a typo fails fast with the known profiles listed
(`unknown profile 'wrok'; known profiles: personal, work`) before any backend
call is made. Validation loads settings from disk *only* when a profile is
actually active, so a no-profile invocation reads no file.

### Auth login/logout read *and* write the active profile

For profiles to be usable without hand-editing JSON, the write side mirrors the
read-side isolation (#1116). `Settings::upsert_env_vars_in` /
`remove_env_vars_in` take an explicit `Option<&str>` profile and target
`profiles.<name>.env` when one is given, the base `env` when not. So
`omni-dev --profile work atlassian auth login` (and the Datadog flows) store
credentials exactly where a later `--profile work` invocation will read them;
`auth logout` removes only the active profile's keys, leaving the base map and
other profiles intact. The base-`env` helpers (`upsert_env_vars` /
`remove_env_vars`) delegate with `None`, preserving external-caller
compatibility. The file is read/written as a generic JSON value, so unknown
fields and other profiles survive verbatim.

### Consequences of isolation, handled as first-class decisions

- **Truly-shared keys must be duplicated.** A value every profile needs (e.g.
  `CLAUDE_API_KEY`, `DATADOG_SITE`) has to be copied into each profile's `env`,
  because the base map is invisible while a profile is active. This duplication
  is the accepted cost of fail-loud isolation, and is documented for users in
  [docs/configuration-best-practices.md](../configuration-best-practices.md).
- **More plaintext tokens on disk.** Several profiles multiply the credential
  material sitting in `settings.json`, which motivated hardening the single write
  site to a `0600` file inside a `0700` `~/.omni-dev/`, re-tightened on every
  write (#1153/#1128) — matching the daemon runtime-state posture.
- **Non-CLI consumers need client-side resolution.** `OMNI_DEV_PROFILE` is a
  property of the invoking CLI process; it does **not** cross the daemon process
  boundary. The Snowflake daemon service resolved its `SNOWFLAKE_*` defaults once
  at startup, so `omni-dev --profile work snowflake query` silently hit the
  startup tenant. The fix (#1110) resolves the six identity/context defaults
  **client-side** through the same profile-aware env-then-settings chain
  (`QueryRequest::fill_defaults_from`) and sends the resolved values with the
  request, so the profile is honored across the socket. Any future daemon-hosted,
  credential-scoped service must resolve profile-scoped values on the client
  side, not in the resident process.
- **`OMNI_BRIDGE_TOKEN` is a deliberate carve-out.** The browser-bridge token is
  read from the **raw process environment only** and ignores both the base `env`
  map and profiles. Future readers of the credential-resolution code must be told
  this one variable does not participate.

## Consequences

**Positive:**

- **Fail-loud tenant safety.** A missing per-tenant credential surfaces as an
  error instead of silently authenticating against another tenant — the whole
  point of choosing isolation over the walk-up/merge instinct used elsewhere.
- **Familiar mental model.** `--profile` / `OMNI_DEV_PROFILE` behave like the AWS
  CLI's `--profile` / `AWS_PROFILE`, including flag-beats-env-var precedence and
  the hard error on an unknown profile.
- **Backward compatible.** With no `profiles` map and no profile selected,
  behaviour is byte-for-byte identical to a plain `env`-only `settings.json`;
  existing single-tenant users are unaffected.
- **Write follows read.** `auth login`/`logout` populate and clear the active
  profile, so multi-tenant setup needs no hand-editing of `settings.json`.

**Negative:**

- **Shared keys are duplicated.** Isolation means no single place for values
  common to every profile; they must be repeated per profile. Accepted as the
  cost of the safety property.
- **More secrets on disk.** Multiple profiles concentrate more plaintext tokens
  in one file — mitigated, not eliminated, by the `0600`/`0700` hardening.
- **The abstraction does not auto-cross the daemon boundary.** Profile scope is a
  CLI-process concept, so each daemon-hosted service that honors profiles must
  opt in with client-side resolution (as Snowflake now does).

**Neutral:**

- **One documented carve-out.** `OMNI_BRIDGE_TOKEN` intentionally bypasses the
  `env`/profile chain; this is a known exception, not an oversight.
- **Selection is per invocation, not sticky.** There is no "current profile"
  state; every command re-selects, which keeps resolution a pure function of the
  process environment plus the settings file.

See [ADR-0005](adr-0005.md) and [ADR-0022](adr-0022.md) for the walk-up and
merge patterns this decision deliberately inverts, and
[docs/configuration-best-practices.md](../configuration-best-practices.md)
§"Credential Profiles" for the operator-facing how-to.
