From b331226d8acc2fcce6521792bd5b7e3a91054232 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:03:48 +0000 Subject: [PATCH 1/3] dir-sim plan V19: security and mail as two group properties, two nested closures A group is security-enabled or not and mail-enabled or not, read from groupType / securityEnabled and mailEnabled / the recipient attributes. Nested groups are expanded once in lance-graph, as two closures: delivery walks mail-enabled groups, security walks only security-enabled ones, as AD's token does. Permissions (V17, V18, RBAC roles) use the security closure; an unread security flag is excluded and fails closed. V17's trustee must be a security-enabled group; a grant to a distribution group confers nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- docs/DIRECTORY-SIMULATION-POC.md | 59 ++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index fd26920..97f02ce 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -474,13 +474,16 @@ no-population-intermediate rule. - **The object.** FullAccess needs a mailbox; a grant of it on anything else is a violation. SendAs and SendOnBehalf also apply to a distribution group, which has no mailbox of its own. - - **The trustee is a user or a group.** A user holds a right on an - object when it is granted to the user or to any group the user belongs - to, transitively. This depends on nested-group expansion, which dir-sim - does not have yet: a nested group is held by identity and never - expanded (lance-graph `lance-graph-dir-sim/src/snapshot.rs`, W-4 of - lance-graph's HubSPO-rs handover). Until it lands, a grant to a group - reaches only the group's direct members. + - **The trustee is a user or a security-enabled group** (V19). A user + holds a right on an object when it is granted to the user or to a + security-enabled group the user belongs to, directly or through other + security-enabled groups: V19's security closure, not its delivery + closure. A grant to a group that is not security-enabled (a + distribution group) is a violation and confers nothing. Exchange + Online resolves trustees among recipients, so there a group trustee + must also be mail-enabled; on-premises a security group that is not + mail-enabled is a valid trustee. Until V19's expansion lands, a grant + to a group reaches only the group's direct members. - **Deny wins.** Exchange allows deny entries on FullAccess, and Active Directory allows them on SendAs. A deny for `(object, trustee, right)` from any witness, for the user or for any group the user belongs to, @@ -556,5 +559,47 @@ no-population-intermediate rule. SharePoint's implicit role on an ancestor of a shared item; where the folder tree lives (Spear's `DriveScope` names a drive and an item, not a path). +- **V19 — Group kinds and nested groups (design, 2026-10-10).** A group + has two independent properties, and dir-sim records both: + - **Security-enabled**: the group can hold permissions. AD sets bit + `0x80000000` of `groupType`; Graph reports `securityEnabled`. + - **Mail-enabled**: the group has addresses and receives mail. Graph + reports `mailEnabled`; on-premises it is the group's Exchange + recipient attributes, decoded like a user's. + + That gives three kinds in practice: a security group (not mail-enabled), + a mail-enabled security group, and a distribution group (mail-enabled, + not security-enabled). A Microsoft 365 group (`groupTypes` contains + `Unified`) is mail-enabled and may be security-enabled; it has no + on-premises equivalent unless written back. An unread flag stays + unknown, never false: a group read from a source that did not report + `groupType` or `securityEnabled` is not treated as a distribution group. + + Decisions: + - **Nesting is expanded in lance-graph** (W-4 of lance-graph's HubSPO-rs + handover), once, not by each consumer. Spear's `mailbox_members` and + HubSPO-rs's routing are its first two users. + - **Two closures, because the two questions differ.** + - *Delivery*: every user reached through nested mail-enabled groups, + which is how a distribution list is expanded. + - *Security*: every user reached through nested security-enabled + groups only. A security group nested in a distribution group gives + the distribution group's members no permission, as in AD, where a + token (`tokenGroups`) holds only security groups. Permission checks + (V17, V18, RBAC roles from group membership) use this closure. + - **Unknown is excluded from the security closure.** A group whose + security flag was not read is not walked for permissions, and a + permission that depends on it is reported, so it fails closed. The + delivery closure walks a group whose mail flag was not read, as + today's snapshots do. + - **Cycles are walked once**; both closures are in user-ordinal order. + - **Validation.** A permission grant to a group that is not + security-enabled is a violation (V17). A security group whose + security flag was read as cleared by a later observation loses every + grant made to it, which the diff reports. + + Not modelled: group scope (global, domain local, universal) and the + nesting rules between scopes; dynamic membership rules + (`DynamicMembership`), whose members are read as observed. - **V5 — CI.** CI builds `lance-graph-dir-sim` against the OGAR checkout, so it needs this OGAR PR merged first. From 55ac61d5425784ce2c1646700467773b0fb02da0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:10:18 +0000 Subject: [PATCH 2/3] ogar-ad schema v7: groupType, and whether a group is security-enabled groupType is read into the last numeric slot, bit-cast like the other signed LDAP integers. is_security_enabled reads its high bit (ADS_GROUP_TYPE_SECURITY_ENABLED); mail-enabled stays a separate question. A record of an older schema did not read it. V19 now matches the implementation: mail-enabled is a primary SMTP address, and a group created in a simulated version carries no security flag. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-ad/src/lib.rs | 24 ++++++++++++++++++++- crates/ogar-ad/tests/main.rs | 37 ++++++++++++++++++++++++++++++++ docs/DIRECTORY-SIMULATION-POC.md | 13 +++++------ 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/crates/ogar-ad/src/lib.rs b/crates/ogar-ad/src/lib.rs index 0743660..62d21a2 100644 --- a/crates/ogar-ad/src/lib.rs +++ b/crates/ogar-ad/src/lib.rs @@ -25,7 +25,7 @@ use ogar_dir_core::{ }; /// Encoder schema version understood by this crate. -pub const SCHEMA_VERSION: u16 = 6; +pub const SCHEMA_VERSION: u16 = 7; /// This crate's schema id. pub const SCHEMA: SchemaId = SchemaId { family: SchemaFamily::AdDs, @@ -70,6 +70,12 @@ pub const SCHEMA: SchemaId = SchemaId { /// it as the mailbox's `ExchangeGuid` across a move, so it is the mailbox /// half of an object's Exchange identity (`ogar-dir-sim::ExchangeIdentity`) /// and, like the source anchor, never text. +/// +/// v7 adds `groupType` in the last numeric slot, bit-cast like every signed +/// LDAP integer here. Its high bit says whether a group is security-enabled +/// ([`is_security_enabled`]); whether it is mail-enabled is a separate +/// question answered by the recipient attributes. A record of an older +/// schema did not read it, which is not the same as a cleared bit. pub const SCHEMA_V1: &[AttrDef] = &[ AttrDef { name: "distinguishedName", @@ -209,8 +215,24 @@ pub const SCHEMA_V1: &[AttrDef] = &[ kind: AttrKind::Guid, since: 6, }, + AttrDef { + name: "groupType", + slot: 3, + kind: AttrKind::U32, + since: 7, + }, ]; +/// `groupType` bit set on a security-enabled group +/// (`ADS_GROUP_TYPE_SECURITY_ENABLED`). A group without it is a +/// distribution group: it can be mail-enabled but holds no permissions. +pub const GROUP_TYPE_SECURITY_ENABLED: u32 = 0x8000_0000; + +/// Whether a stored `groupType` marks a security-enabled group. +pub fn is_security_enabled(group_type: u32) -> bool { + group_type & GROUP_TYPE_SECURITY_ENABLED != 0 +} + /// AD object kinds (family-scoped codes in `object_kind`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u16)] diff --git a/crates/ogar-ad/tests/main.rs b/crates/ogar-ad/tests/main.rs index 4493777..fefed76 100644 --- a/crates/ogar-ad/tests/main.rs +++ b/crates/ogar-ad/tests/main.rs @@ -416,3 +416,40 @@ fn the_exchange_guid_is_an_id_not_a_string() { let (mut dict, mut pool) = (OuDictionary::new(), ValuePool::new()); assert!(encode(e, domain(), &mut dict, &mut pool, 1).is_err()); } + +// groupType is read raw and bit-cast; its high bit, not the mail attributes, +// says whether a group is security-enabled. Unread stays unread. +#[test] +fn group_type_is_read_and_says_security() { + let group = |gt: Option<&str>| { + let mut text = String::from( + "dn: CN=G,OU=Groups,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: group\n", + ); + if let Some(gt) = gt { + text.push_str(&format!("groupType: {gt}\n")); + } + let e = ldif::parse(&text).unwrap().remove(0); + let (mut dict, mut pool) = (OuDictionary::new(), ValuePool::new()); + let enc = encode(&e, domain(), &mut dict, &mut pool, 1).unwrap(); + assert!(enc.ignored.is_empty(), "{:?}", enc.ignored); + assert_eq!(enc.record.object_kind(), AdKind::Group as u16); + enc.record.num(slot("groupType")) + }; + // Global security group: ADS_GROUP_TYPE_GLOBAL | SECURITY_ENABLED. + let security = group(Some("-2147483646")).unwrap(); + assert_eq!(security, 0x8000_0002); + assert!(ogar_ad::is_security_enabled(security)); + // Universal distribution group. + let distribution = group(Some("8")).unwrap(); + assert!(!ogar_ad::is_security_enabled(distribution)); + // Universal security group. + assert!(ogar_ad::is_security_enabled( + group(Some("-2147483640")).unwrap() + )); + assert_eq!(group(None), None); + // Out of 32-bit range is refused, not wrapped. + let text = "dn: CN=G,OU=Groups,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: group\ngroupType: 4294967296\n"; + let e = &ldif::parse(text).unwrap()[0]; + let (mut dict, mut pool) = (OuDictionary::new(), ValuePool::new()); + assert!(encode(e, domain(), &mut dict, &mut pool, 1).is_err()); +} diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index 97f02ce..5620454 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -564,8 +564,9 @@ no-population-intermediate rule. - **Security-enabled**: the group can hold permissions. AD sets bit `0x80000000` of `groupType`; Graph reports `securityEnabled`. - **Mail-enabled**: the group has addresses and receives mail. Graph - reports `mailEnabled`; on-premises it is the group's Exchange - recipient attributes, decoded like a user's. + reports `mailEnabled`; dir-sim reads it as the group having a primary + SMTP address (lance-graph `View::is_mail_recipient`), the same rule it + already applied to distribution lists. That gives three kinds in practice: a security group (not mail-enabled), a mail-enabled security group, and a distribution group (mail-enabled, @@ -588,10 +589,10 @@ no-population-intermediate rule. token (`tokenGroups`) holds only security groups. Permission checks (V17, V18, RBAC roles from group membership) use this closure. - **Unknown is excluded from the security closure.** A group whose - security flag was not read is not walked for permissions, and a - permission that depends on it is reported, so it fails closed. The - delivery closure walks a group whose mail flag was not read, as - today's snapshots do. + security flag was not read is not walked for permissions, so a + permission that depends on it fails closed. A group created in a + simulated version has no flag either (a `Change` carries none) until + `NodeState` grows one. - **Cycles are walked once**; both closures are in user-ordinal order. - **Validation.** A permission grant to a group that is not security-enabled is a violation (V17). A security group whose From 076ca611718347e2c6163c42951b7c65fe68a67e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:15:10 +0000 Subject: [PATCH 3/3] dir-sim plan V17/V18: per-right trustee kinds, ACE precedence, SendOnBehalf source, link scope and expiry Review findings left open when #344 merged: - FullAccess and SendAs need a user or a security-enabled group; SendOnBehalf also accepts mail-enabled distribution groups and is reached through the delivery closure. - Denies follow Windows canonical ACE order with IsInherited kept; an explicit allow outranks an inherited deny; an unreported level is treated as explicit. - SendOnBehalf is read from the GrantSendOnBehalfTo property via Get-Mailbox / Get-DistributionGroup / Get-DynamicDistributionGroup. - SendAs on a distribution group has no impersonation target; the consumer refuses it until a group-sending path exists. - existingAccess links grant nothing; expired grants are refused; caller-scoped Graph permission reads never decide others' access. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- docs/DIRECTORY-SIMULATION-POC.md | 91 ++++++++++++++++++++++---------- 1 file changed, 64 insertions(+), 27 deletions(-) diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index 5620454..4c547d6 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -474,28 +474,48 @@ no-population-intermediate rule. - **The object.** FullAccess needs a mailbox; a grant of it on anything else is a violation. SendAs and SendOnBehalf also apply to a distribution group, which has no mailbox of its own. - - **The trustee is a user or a security-enabled group** (V19). A user - holds a right on an object when it is granted to the user or to a - security-enabled group the user belongs to, directly or through other - security-enabled groups: V19's security closure, not its delivery - closure. A grant to a group that is not security-enabled (a - distribution group) is a violation and confers nothing. Exchange - Online resolves trustees among recipients, so there a group trustee - must also be mail-enabled; on-premises a security group that is not - mail-enabled is a valid trustee. Until V19's expansion lands, a grant - to a group reaches only the group's direct members. - - **Deny wins.** Exchange allows deny entries on FullAccess, and Active - Directory allows them on SendAs. A deny for `(object, trustee, right)` - from any witness, for the user or for any group the user belongs to, - removes the right whatever grants exist. A deny that cancels a grant - is reported, so it is visible rather than silent. Reading grants - without denies would fail open. + - **Which trustees each right accepts.** Exchange does not accept the + same group kinds for every right (Microsoft, "Manage permissions for + recipients"), so the group's kind (V19) is validated per right: + - **FullAccess and SendAs**: a user or a security-enabled group. A + user holds the right when it is granted to the user or to a + security-enabled group it belongs to, through V19's security + closure. Exchange Online resolves these trustees among recipients, + so there the group must also be mail-enabled; on-premises a + security group that is not mail-enabled is a valid trustee. A grant + of either to a distribution group is a violation and confers + nothing. + - **SendOnBehalf**: a user or a mail-enabled group, distribution + groups included, because `GrantSendOnBehalfTo` names recipients. A + user holds it through V19's delivery closure. A grant to a group + that is not mail-enabled is a violation. + + Until V19's expansion lands, a grant to a group reaches only the + group's direct members. + - **Denies follow ACE precedence.** Exchange allows deny entries on + FullAccess, and Active Directory allows them on SendAs. Each entry + keeps whether it is inherited (`IsInherited` in + `Get-MailboxPermission` / `Get-EXOMailboxPermission`; the ACE's + inherited flag on-premises), and entries are evaluated in Windows + canonical order: explicit deny, explicit allow, inherited deny, + inherited allow. So a deny removes a right unless an entry of a + stronger level allows it; an explicit allow outranks an inherited + deny. A group entry is at the level of its own ACE, not lower for + being reached through a group. A deny whose level was not reported + (a witness that carries no inheritance) is treated as explicit, so it + fails closed, and a deny that cancels an allow is reported rather + than applied silently. Reading allows without denies would fail open. - **Each grant carries the witness that asserted it.** LDIF supplies `publicDelegates` and `msExchDelegateListLink`; Graph supplies none of - the three; the Exchange permission output (`Get-MailboxPermission`, - `Get-RecipientPermission`, `GrantSendOnBehalfTo`) supplies all of them - and is read through the same read-only encoder as V12. Witnesses that - disagree both stay; an absent witness is not a refusal. + the three. Exchange supplies all three, each from its own read, + through the same read-only encoder as V12: FullAccess from + `Get-MailboxPermission`, SendAs from `Get-RecipientPermission` (on- + premises `Get-ADPermission` with the `Send-As` extended right), and + SendOnBehalf from the `GrantSendOnBehalfTo` property of the + recipient, read with `Get-Mailbox`, `Get-DistributionGroup` or + `Get-DynamicDistributionGroup`; it is a property, not a cmdlet of its + own. Witnesses that disagree both stay; an absent witness is not a + refusal. - **Consumers.** Spear's mailbox IAM lets a FullAccess trustee read the mailbox's rows. SendAs and SendOnBehalf gate sending, and the consumer checks the right before every submission; Stalwart does not know about @@ -505,10 +525,14 @@ no-population-intermediate rule. account's addresses (`crates/jmap/src/identity/set.rs`). So: - **SendOnBehalf** is sent from the trustee's own account and identity, with `From` set to the object and `Sender` to the trustee. - - **SendAs** cannot be sent from the trustee's account, which has no - identity with the object's address. It is submitted to the object's - own account through master-user impersonation (`object%master`, - `Impersonate` permission), after the V17 check. + - **SendAs on a mailbox** cannot be sent from the trustee's account, + which has no identity with the object's address. It is submitted to + the object's own account through master-user impersonation + (`object%master`, `Impersonate` permission), after the V17 check. + - **SendAs on a distribution group** has no such route: the group has + no account or mailbox to impersonate. Until a group-sending path is + defined (open), the consumer refuses that submission rather than + sending it from some other account. Not modelled: folder-level permissions (`Add-MailboxFolderPermission`) and calendar delegates. @@ -518,8 +542,9 @@ no-population-intermediate rule. write, owner) to a principal on a site, library, folder or item. An item inherits its parent's grants until inheritance is broken. A sharing link has a scope: `anonymous` admits whoever holds it, `organization` admits a - user signed in to the same tenant who holds it, and `users` admits only - the people it was granted to. Graph reports a drive item's `permissions` + user signed in to the same tenant who holds it, `users` admits only the + people it was granted to, and `existingAccess` grants nothing (it lets + people who already have access find the item). Graph reports a drive item's `permissions` with `roles`, `grantedToV2`, `grantedToIdentitiesV2` and `link` (`link.scope`). It does not return `inheritedFrom` for SharePoint and OneDrive for Business, so the permission list cannot say which ancestor @@ -543,6 +568,13 @@ no-population-intermediate rule. them. Until that flag is read, the grants on an item are its effective permissions as Graph returns them, with no inheritance inferred. + - **The observation must be complete.** Graph returns all of an item's + sharing permissions only to its owner or to an application with + site-wide permission; a delegated caller who is not an owner gets + only the permissions that apply to it. Ingestion therefore reads in + an owner or application context. A permission set read in any other + context is marked caller-scoped, and never decides access for anyone + but that caller. - A principal's effective role is the highest role granted to it, or to a group it belongs to, on the item or on an ancestor up to the nearest broken inheritance. @@ -551,7 +583,12 @@ no-population-intermediate rule. admits a holder signed in to the tenant (an external or guest identity from another tenant is refused), and `users` admits only the identities in `grantedToIdentitiesV2`, which are evaluated as ordinary principals. - A link is never folded into one shared link principal. + An `existingAccess` link grants nothing: its holder is evaluated on + the grants they already have. A link is never folded into one shared + link principal. + - A grant keeps its `expirationDateTime`, and an expired grant is + refused at authorization time, not only when the next observation + drops it. - Spear evaluates it with an RBAC plug over the document classid, the way `RBAC_PLUG` covers mail.