Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
35b71bd
Add the OIDC id token verifier and the SsoApi port (D91, first part)
rorychatt Oct 31, 2026
70d32de
Test the Microsoft sign-in at the provider level, seal its secret for…
rorychatt Oct 31, 2026
4474e8c
Add the Microsoft sign-in routes, the account commands and the admin …
rorychatt Oct 31, 2026
b8817d2
Add the Microsoft sign-in button, the one-time code exchange, transla…
rorychatt Oct 31, 2026
553e705
Record D91 (Proposed), document the Microsoft sign-in and list its li…
rorychatt Oct 31, 2026
76834b8
Answer the favicon request of the Entra stand-in (D91 e2e)
rorychatt Oct 31, 2026
bc6b9ea
Format the e2e files of the Microsoft sign-in (D91)
rorychatt Oct 31, 2026
35dba34
Merge origin/main (OR-36 and OR-37) into feat/oidc-core
rorychatt Oct 31, 2026
e3a2e2e
OIDC core (D91): fix round 1 for PR 37 (scope, tenant pin, guards on …
rorychatt Oct 31, 2026
0e0be46
e2e: type the cookie-jar helper of the SSO spec without the Playwrigh…
rorychatt Oct 31, 2026
13825ea
OIDC core (D91): fix round 2 for PR 37 (test authority gate, log thro…
rorychatt Oct 31, 2026
d23ab31
OIDC core (D91): pin the number of waiting requests in its test
rorychatt Oct 31, 2026
73c0a5e
OIDC core (D91): pin the epoch refusal line, the report reset and the…
rorychatt Oct 31, 2026
95a2f30
OIDC core (D91): commit the mutation table that D91, Q64 and N3 cite
rorychatt Oct 31, 2026
fb3c283
OIDC core (D91): fix round 3 for PR 37, code and tests
rorychatt Nov 1, 2026
5a55893
OIDC core (D91): fix round 3 for PR 37, docs
rorychatt Nov 1, 2026
ab3d42b
OIDC core (D91): pin the line of a refused id token to the failure bu…
rorychatt Nov 1, 2026
cdc8c1f
OIDC core (D91): make the timing tests of the sign-in wait for the st…
rorychatt Nov 1, 2026
a1840ba
OIDC core (D91): the mutation table made again on the rewritten timin…
rorychatt Nov 1, 2026
af6fa2d
OIDC core (D91): fix round 4 for PR 37, tests and docs
rorychatt Nov 1, 2026
778524c
OIDC core (D91): fix round 4 for PR 37, N20 (l) and (m) and the mutat…
rorychatt Nov 1, 2026
a4c3d00
Merge origin/main (cf317da) into feat/oidc-core
rorychatt Nov 1, 2026
7a767ab
Merge origin/main (9555614) into feat/oidc-core
rorychatt Nov 1, 2026
4c2b91c
Docs: five wording corrections to the OIDC notes (F1, F2, F4, F6, r4m-1)
rorychatt Nov 1, 2026
3d97de5
Merge origin/main (33f48c6) into feat/oidc-core
rorychatt Nov 2, 2026
66e8255
Docs: D91 is ratified (OR-38, 2026-11-02)
rorychatt Nov 2, 2026
e093b72
Comments: D91 is ratified (OR-38), not Proposed
rorychatt Nov 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Open-source operating system for businesses: an Odoo-class modular suite (ERP, CRM, accounting, inventory, manufacturing, HR, projects, POS, e-commerce, website, marketing, helpdesk, documents) on one shared data model. Clean-room re-implementation of Odoo's concepts and behaviour, not a code port. Apache-2.0 and completely free to use and to self-host.

The owner's rulings of 2026-10-01 (OR-1 to OR-21), 2026-10-27 (OR-22 to OR-34), 2026-10-28 and 2026-10-29 (OR-35 to OR-37) are recorded in `docs/DECISIONS.md`, sections "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)". They are RATIFIED and win over every Proposed position in every document and in the research. Anything the owner has not ruled stays PROPOSED or open; do not treat it as decided.
The owner's rulings of 2026-10-01 (OR-1 to OR-21), 2026-10-27 (OR-22 to OR-34), 2026-10-28 and 2026-10-29 (OR-35 to OR-37) and 2026-11-02 (OR-38) are recorded in `docs/DECISIONS.md`, sections "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)". They are RATIFIED and win over every Proposed position in every document and in the research. Anything the owner has not ruled stays PROPOSED or open; do not treat it as decided.

## Hard tech constraints (set by the owner)

Expand Down Expand Up @@ -33,7 +33,7 @@ The owner's rulings of 2026-10-01 (OR-1 to OR-21), 2026-10-27 (OR-22 to OR-34),
## Layout

- `README.md` — entry point and document map. `LICENSE`, `NOTICE` — Apache-2.0 text and attribution (OR-1).
- `docs/PRODUCT.md` — master product description. `docs/DECISIONS.md` — binding decision register (wins over the research where they differ); its sections "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)" hold OR-1 to OR-37 and win over every Proposed position.
- `docs/PRODUCT.md` — master product description. `docs/DECISIONS.md` — binding decision register (wins over the research where they differ); its sections "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)" hold OR-1 to OR-38 and win over every Proposed position.
- `docs/CONVENTIONS.md` — naming and contract authority (identifiers, events, permission groups, data classes, ports); it ranks below `docs/DECISIONS.md` and above every other document.
- `docs/ARCHITECTURE.md`, `docs/ROADMAP.md`, `docs/BUSINESS.md`, `docs/LOCALIZATION.md`, `docs/ENGINEERING.md`, `docs/GLOSSARY.md`.
- `docs/spec/` — per-domain product specifications. `docs/spec/cli.md` is the agentic-first command surface: the `obop` CLI, its REST and MCP counterparts and the free plugin registry client (OR-3, OR-4); parts of it are built (the `obop` binary), the rest is PROPOSED.
Expand All @@ -50,6 +50,7 @@ The owner's rulings of 2026-10-01 (OR-1 to OR-21), 2026-10-27 (OR-22 to OR-34),
- Modules and platform: `obop-pkg` (obop.toml X1 packages: parse, gate, semver and SPDX checks, dependency resolution and install plans, runtime install/uninstall, marketplace; admin commands `module.*`, `module_plan.preview` and `marketplace_listing.*`; D55: one core version `obop_types::CORE_VERSION`, module metadata, versioned dependencies), `obop-platform` (control plane: accounts, instances, provisioners local/sliplane/fake). `packages/*.toml` is the bundled catalog; translations (D52) are `[labels.<lang>]` tables in a package document, bundled as `packages/labels/<lang>/<id>.toml` (de, fr, pl, fi, sv, es; also for the built-in modules), checked by `obop modules check --complete FILE`, templated by `obop modules labels TARGET LANG`. A text field can show the label of an option of a select (`shows_option_of` and `when` in `[[model.field]]`, D52 amendment): `ShowsOption` in `obop-registry` `rules.rs`, the gate `expr::check_display_subset`, `shows_option_of` and `shows_option_when` in `/api/meta`; the browser evaluates the condition (`displayCondition.ts`, `shownOption.ts` in the backoffice), and `displayCondition.fixtures.json` is read by both it and `obop-commands/src/shows_option_tests.rs`. See `docs/DEPLOY.md`; Sliplane project `obop` (`project_2x9d8q51m9lt`, account `spacecorps`) exists and is empty.
- Updates (D63, `docs/DEPLOY.md` "Updating from inside"): every package declares `requires_core` and a range per dependency; `module.upgrade|upgrade_all|rollback` (atomic batch, `[[migration]]` data steps in `obop-pkg::migrate`, D59 lock, audited, agents propose); `obop-update` (release feed, core compatibility of installed modules, `instance_update.check|status|start|rollback`, `obop update ...`) asks the control plane (`obop-platform::update`: per-instance update token, `GET /api/releases`, rollback to the previous image when the new one does not start).
- OAuth sign-in for MCP clients (D64, `docs/DEPLOY.md` "Connecting Claude"): `obop-oauth` (the authorization server: dynamic registration of public clients, authorization code with mandatory PKCE S256 and the RFC 8707 resource, rotating refresh tokens with reuse detection, revocation, the server-rendered sign-in and consent pages in 7 languages; state in the shared storage as `oauth_client`, `oauth_grant`, `oauth_refresh` records and expiring codes and flows, everything secret stored as SHA-256 hashes), `obop-http::oauth` (routes under `/oauth/*`, `/.well-known/oauth-authorization-server`, `/api/auth/oauth/grants`), the `OAuthTokens` trait of `obop-commands` implemented by `obop-auth` (access tokens are one hour scoped agent tokens bound to the `/mcp` audience: refused on `/api`), the Connected apps table in Customize, Security, and `security_event` kinds `oauth_*`. Not yet tried against the real Claude apps. Mobile class (D64 amendment, `docs/DEPLOY.md` "Mobile apps", `docs/spec/mobile.md` 8.5): a client registers with `obop_client_class: "mobile"`, RFC 8252 redirects (`redirect::validate_mobile`: a private-use scheme or an `http` loopback address, never `https`) and the groups it declares (`scope`); the consent page offers only declared groups the person holds that a phone may have (`devices::never_for_mobile` and `NEVER_FOR_MOBILE` exclude administrative and approving groups by pattern and by name; CONVENTIONS 4) and ticks none unasked; its one hour access token has the `/api` audience, is the person narrowed to the groups handed over (stored as an `agent` token with the marker `mobile` and its device; `Principal::is_person` is false for it and `Principal::device` names its device, so the account routes refuse it and it reaches only its own device), and is refused on `/mcp`; the sign-in is bound to the credential epoch (K10); each sign-in is a `device` record (`obop_commands::devices`, `may_see` is the one rule, the model in `obop-pkg::devices`, commands `device.get|list|search|update|revoke`, group `api.device_admin`; at most 20 per person) bound to the refresh family; `obop-oauth::devices` implements `DevicesApi`; the identity provider ends devices with sessions on a credential change and on offboarding (`Auth::end_devices`, each one logged before it is ended). Not tried with a real app or phone.
- Microsoft sign-in (D91, RATIFIED by OR-38 on 2026-11-02, item 3a, `docs/DEPLOY.md` "Sign in with Microsoft"): `obop-auth/src/oidc/` (the RS256 id token verifier on `ring`, the records `auth_sso_provider` and `auth_sso_link`, the flag `sso_only`, the one-time code, the scope `openid profile`, the pinned tenant, the break-glass guard `BREAK_GLASS_MIN = 2`), the `SsoApi` port of `obop-commands`, `obop-http/src/sso.rs` (`/api/auth/sso`, `/api/auth/oidc/*`), the commands `sso_provider.configure|clear` and `user_account.bind_sso|unbind_sso|set_sso_only`, CLI `obop admin sso status|configure|clear|bind|unbind|only`; only an administrator binds an identity (the first-bind rule is 3b: ratified, not built); a Microsoft-only account has its password refused everywhere, also by `Auth::password_ok` for a signed-in session, so a later re-authentication built on it inherits that; the verifier keeps `auth_time` (a step-up must read it from the verified token, never the server clock); nothing ran against a real Entra tenant, test authority `OBOP_OIDC_TEST_AUTHORITY`; the stand-ins are `oidc/soft.rs`, `crates/obop/src/sso_tests.rs` and `web/packages/e2e/support/idp.ts`.
- Connectors (D54, `docs/CONNECTORS.md`): `obop-connectors` (the egress and ingress gate with treatment by data class, the one hardened outbound client, signed webhooks, OAuth2 with PKCE, sealed secrets, retention and erasure) and the built-in module `connectors`; every flow in or out is recorded, minimised and revocable, and the docs say plainly that this supports GDPR compliance and is not legal advice. Credentials are sealed with `OBOP_SECRET_KEY`; `OBOP_CONNECTORS_ALLOW_LOOPBACK=1` allows loopback targets and is for tests only; `obop connectors purge` applies retention.
- UI: `web/apps/backoffice` renders from `/api/meta` (D46): app launcher, control panel, list/kanban, record side panel, Customize app (theme, apps, the Modules page with cards, dependency panel and plan dialog, module builder). `web/apps/platform` is the sign-up app, `web/apps/site` the multi-page website (VitePress, docs, 7 languages; see docs/site/SITE.md); `deploy/build-site.sh` assembles the site and the sign-up app into `web/site`. Forum and status (`obop-community`, docs/COMMUNITY.md) and each instance's public shop (`obop-site`, docs/STOREFRONT.md, `obop serve --public-site`) are server-rendered Rust.
- Tests: `cargo test` (Rust, add `OBOP_TEST_POSTGRES_URL` for PostgreSQL), and `web/packages/e2e` (Playwright over the real built app, about 190 tests; see its README; CI runs it). 23 bundled marketplace packages live in `packages/*.toml` (typed fields, D47).
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion crates/kernel/obop-auth/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ repository.workspace = true
obop-commands.workspace = true
obop-storage.workspace = true
obop-keys.workspace = true
obop-types.workspace = true
argon2 = "0.6"
sha2 = "0.11"
getrandom = "0.4"
Expand All @@ -21,7 +22,8 @@ sha1 = "0.11.0"
ring = "0.17.14"

[features]
# A software authenticator for tests of the WebAuthn flows (never enabled in a release build).
# Test doubles: a software authenticator for the WebAuthn flows, a software Microsoft for the
# sign-in (`oidc::soft`) and `Auth::set_oidc_authority_for_test`. Never enabled in a release build.
test-support = []

[lints]
Expand Down
72 changes: 50 additions & 22 deletions crates/kernel/obop-auth/src/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,13 @@ impl Auth {
));
}
let u = self.user_record(name)?;
// A Microsoft-only account has no password to change (the hash that is left is not
// accepted by `password_ok`); say so instead of "wrong password".
if u["sso_only"].as_bool() == Some(true) {
return Err(MfaError::Invalid(
"this account signs in with Microsoft only: there is no password to change".into(),
));
}
if !self.password_ok(name, current)? {
self.record_failure(name).map_err(MfaError::Unavailable)?;
self.event(
Expand Down Expand Up @@ -540,13 +547,29 @@ impl Auth {
*passkeys.entry(o.to_string()).or_default() += 1;
}
}
// Who is bound to a Microsoft identity (D91): the object id, never a name or an address.
let mut bound: std::collections::HashMap<String, String> = Default::default();
for (_, l) in self
.backend
.list(crate::oidc::LINK_TABLE)
.unwrap_or_default()
{
if let (Some(u), Some(o)) = (
l.get("user").and_then(J::as_str),
l.get("oid").and_then(J::as_str),
) {
bound.insert(u.to_string(), o.to_string());
}
}
let mut users: Vec<J> = self
.backend
.list("auth_user")
.unwrap_or_default()
.into_iter()
.map(|(name, u)| {
json!({
"sso_only": u.get("sso_only").and_then(J::as_bool) == Some(true),
"sso_oid": bound.get(&name),
"name": name,
"groups": str_list(&u["groups"]),
"admin": str_list(&u["groups"]).iter().any(|g| g == ADMIN_GROUP),
Expand All @@ -564,22 +587,6 @@ impl Auth {
Ok(json!({"users": users}))
}

/// How many enabled administrators there are. A refused list is the store's trouble, not
/// "none": the callers refuse to take the last administrator away, and must not do that (a 400)
/// or skip it (a lockout) because the lookup failed.
pub(crate) fn enabled_admins(&self) -> Result<usize, MfaError> {
Ok(self
.backend
.list("auth_user")
.map_err(MfaError::Unavailable)?
.into_iter()
.filter(|(_, u)| {
str_list(&u["groups"]).iter().any(|g| g == ADMIN_GROUP)
&& u.get("disabled").and_then(J::as_bool) != Some(true)
})
.count())
}

pub(crate) fn set_user_disabled_impl(
&self,
admin: &Principal,
Expand Down Expand Up @@ -611,11 +618,21 @@ impl Auth {
let u = self.known_user(&target)?;
let is_admin = str_list(&u["groups"]).iter().any(|g| g == ADMIN_GROUP);
let was = u["disabled"].as_bool() == Some(true);
if disabled && !was && is_admin && self.enabled_admins()? <= 1 {
return Err(MfaError::Invalid(
"the last enabled administrator cannot be disabled".into(),
));
// The rule counts the administrators who sign in without Microsoft only (D91): an
// account that has no sign-in of its own does not keep the instance reachable. A refused
// list is the store's trouble, not "none".
if disabled && !was && is_admin && self.other_local_admins(&target)? == 0 {
return Err(MfaError::Invalid(format!(
"the last enabled administrator cannot be disabled{}",
self.microsoft_only_admins_note()
)));
}
// While accounts depend on Microsoft, two administrators keep a sign-in of their own.
let warning = if disabled && !was {
self.guard_break_glass(&target, actor_user.is_none())?
} else {
None
};
let mut patch = Fields::new();
patch.insert("disabled".into(), J::Bool(disabled));
bump_epoch(&mut patch);
Expand Down Expand Up @@ -653,7 +670,10 @@ impl Auth {
&target,
"user_disable",
actor,
&format!("account disabled; {n} session(s) signed out; its tokens stop working"),
&format!(
"account disabled; {n} session(s) signed out; its tokens stop working{}",
warning.map(|w| format!("; {w}")).unwrap_or_default()
),
);
} else if was_offboarded {
self.event(
Expand Down Expand Up @@ -687,7 +707,15 @@ impl Auth {
)));
}
let _g = self.mfa_guard()?;
self.known_user(&target)?;
let user = self.known_user(&target)?;
// An account that signs in with Microsoft only (D91) has no password sign-in, so a
// temporary password would be a way in that the setting was meant to close.
if user["sso_only"].as_bool() == Some(true) {
return Err(MfaError::Invalid(
"the account signs in with Microsoft only: turn that off before setting a password"
.into(),
));
}
let hash = self
.hash_password(temporary)
.map_err(MfaError::Unavailable)?;
Expand Down
37 changes: 27 additions & 10 deletions crates/kernel/obop-auth/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ pub struct ImportOutcome {
/// The name the log shows for the actor and the account it signs in as. An approved proposal of
/// an agent runs as `alice/agent` (what the dispatcher calls the principal), whose account is
/// `alice`; user names cannot contain `/`, so the two cannot be mistaken for one another.
fn admin_of(p: &Principal) -> Result<(&str, &str), MfaError> {
pub(crate) fn admin_of(p: &Principal) -> Result<(&str, &str), MfaError> {
if p.is_person() && p.groups.contains(ADMIN_GROUP) && !p.mfa_limited {
Ok((&p.id, p.id.strip_suffix("/agent").unwrap_or(&p.id)))
} else {
Expand Down Expand Up @@ -316,11 +316,17 @@ impl Auth {
.into(),
));
}
if u["disabled"].as_bool() != Some(true) && self.enabled_admins()? <= 1 {
return Err(MfaError::Invalid(
"the last enabled administrator keeps the group; make another administrator first"
.into(),
));
if u["disabled"].as_bool() != Some(true) && self.other_local_admins(&target)? == 0 {
return Err(MfaError::Invalid(format!(
"the last enabled administrator keeps the group; make another administrator first{}",
self.microsoft_only_admins_note()
)));
}
// While accounts depend on Microsoft, two administrators keep a sign-in of their own.
if u["disabled"].as_bool() != Some(true)
&& let Some(w) = self.guard_break_glass(&target, actor_user.is_none())?
{
detail.push_str(&format!("; {w}"));
}
}
let mut patch = Fields::new();
Expand Down Expand Up @@ -363,11 +369,19 @@ impl Auth {
let _g = self.mfa_guard()?;
let u = self.known_user(&target)?;
let is_admin = str_list(&u["groups"]).iter().any(|g| g == ADMIN_GROUP);
if is_admin && u["disabled"].as_bool() != Some(true) && self.enabled_admins()? <= 1 {
return Err(MfaError::Invalid(
"the last enabled administrator cannot be offboarded".into(),
));
let enabled_now = u["disabled"].as_bool() != Some(true);
if is_admin && enabled_now && self.other_local_admins(&target)? == 0 {
return Err(MfaError::Invalid(format!(
"the last enabled administrator cannot be offboarded{}",
self.microsoft_only_admins_note()
)));
}
// While accounts depend on Microsoft, two administrators keep a sign-in of their own.
let warning = if enabled_now {
self.guard_break_glass(&target, actor_user.is_none())?
} else {
None
};
let note = clean_note(note);
// Disable first: from here no token of the account resolves, whatever happens next.
let mut patch = Fields::new();
Expand Down Expand Up @@ -420,6 +434,9 @@ impl Auth {
if !note.is_empty() {
detail.push_str(&format!("; note: {note}"));
}
if let Some(w) = warning {
detail.push_str(&format!("; {w}"));
}
self.event(&target, "user_offboard", actor, &detail);
Ok(json!({
"user": target,
Expand Down
Loading
Loading