Users and permissions
The three groups the base guarantees, the page-level permission blocks surface-swift ships, and the resolution rule that makes a missing deny an exposure.
What the baseline guarantees
Layer: base 3.2.1 (the groups) + surface-swift 1.5.1 (the page blocks).
Groups (AccessUser rows of type 2 — DW10 has no AccessUserGroup table):
1325 Customers
1270 Account Admin
1292 CSR
These are base-owned under a FILTERED replace:
`AccessUser WHERE AccessUserType = 2 AND AccessUserName IN ('Customers','Account Admin','CSR')`.
Only the groups are base-owned; user rows are NOT serialized into the base.
Users: ZERO. base.contract.json `guaranteedRows.users` lists IMCUser (1328, customer number
98745621) and IMCSalesrep (1326, 7789765) — but those rows ship in the sample-data layer's
merge/_sql/identities.sql, which this composition does not activate. Read that contract
section as "guaranteed when sampleData is true", not "guaranteed".
Page permission blocks (surface-swift replace/_content):
Customer center (root) Anonymous -> none (levelValue 1)
AuthenticatedFrontend -> read (levelValue 4)
Customer center/CSR Account Admin 1270 -> none
and each of its 4 children CSR 1292 -> all (levelValue 1364)
Customers 1325 -> none
Anonymous -> none
AuthenticatedFrontend -> none <-- added in 1.5.0
The explicit AuthenticatedFrontend -> none on the CSR subtree is the fix for a real exposure:
before 1.5.0 the subtree denied three groups but had no AuthenticatedFrontend entry while the
parent granted authenticated read, so ANY signed-in non-denied user reached /csr/accounts.
Permission resolution takes the HIGHEST level across the identities a user carries — a group
grant with no matching deny is an exposure, not a gap.
The permission model is cached in-process. Recycle before believing a negative check.
One ceiling: MCP reaches exactly one corner of the permission store —
assign_permissions_to_assortment. There is no page, paragraph, section or user-group
equivalent. A page gate on this Distribution ships in the layer YAML's `permissions:` block
(which is a serializer concern, not an MCP one), or it is an admin-screen edit. Which skill covers it
dw-users-permissions (dynamo: true) — references/permission-layers.md (the three storage tables and the CapabilityControlFeature flag), references/grant-mechanics.md, references/page-gating.md, references/user-group-operations.md.
Which MCP tools brand it
save_user_groups -> get_user_groups_by_system_name (capture ids) -> create_users -> assign_users_to_group -> save_user_addresses Reads: get_user_by_username, get_user_by_id, get_users_by_group_id, search_users. Two things the tool set does not do, and the guide must say so rather than route around it: - There is NO MCP password tool. create_users makes a login with no usable password. Setting it is an admin-screen operation (Users > the user > the password field). - save_user_groups and update_users are WHOLE-ENTITY saves against the CACHE. Read the entity first and send the complete model, or the omitted columns are re-saved from the cached model and a prior SQL-level change is silently undone.
Verify
MCP: get_user_groups returns 1325 / 1270 / 1292 and nothing else on a clean baseline. Storefront, BOTH auth states, and this is the assert that catches the 1.5.0 class of bug: sign in as a Customers-only user and GET <site-root>/customer-center/csr/accounts. Expected: denied. A 200 with account rows means an AuthenticatedFrontend grant is out-ranking the group deny. Never verify a grant as Angel / BuiltInAdmin / Administrator — those bypass every check. Create a Default-type user in the target group and sign in as them. Do not accept a case-insensitive "the page contains the persona's name" assert as proof of identity; that matches on an anonymous page too. Reading a permission row back is not proof either: PermissionsByIdentifier with an empty SubName returns an empty set for a page that demonstrably has seven permission rows.