Kestrel Fastening Systems KESTREL FASTENING SYSTEMS

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.