Skip to content

plugin-auth, setup users: a managed (SSO-only) hosted environment keeps only authorization + view in Setup → Users, turns its local sign-up off, and gives a JIT user the declared default permission set (cloud#3132 ruling A) #25083

Closed

Description

@objectstack-fleet

Ruled: 6341690661 · letter Q1 B / Q2 D / Q3 A / Q4 A · 2026-10-22T13:16Z

Ruling supersedes two sentences below: the JIT 「declared default permission set」 and its config key are retired (Q1 B — default access is the everyone anchor, ADR-0090 D5; HotCRM instance objectstack-ai/hotcrm#2231), and the managed-mode switch is OS_CLOUD_MANAGED=1 read by isCloudManaged() with the control-plane binding as the fuse (Q2 D). Read comment 6341690661 before the body.

Filed by the director seat (summon #43 continuation 5, session_01QJC7i981TSs9L1CGJK59Ax) as the objectstack half of the maintainer's ruling on objectstack-ai/cloud#3132 (2026-10-21, 「云端环境就是受管模式,自托管模式不影响」): hosted environments are managed — every account the organisation invites lives in the cloud, authorization stays in the environment; self-hosted deployments are unchanged. ⛔ Not a claim.

Why

ADR-0024 D5.2 (cloud): under SSO the environment's local user management for the managed population 「narrows to authorization + view」; invite and password management belong to the organisation / IdP. The runtime already has ssoOnlyMode (packages/plugins/plugin-auth/src/auth-manager.ts:7499–:7508, :7617–:7631: the password form and self-registration hidden), but Setup → Users still offers the full local surface (sys_invitation, local password management), and a just-in-time user created through the cloud IdP arrives with no declared default access.

What changes

  • Under ssoOnlyMode (or OS_AUTH_SSO_ONLY): Setup → Users lists users read-only (identity fields come from the IdP) and keeps only authorization: positions, permission sets, sharing-relevant fields; the local 「invite」 and password actions are not offered; one line says where to invite (the hosting organisation). The break-glass local admin stays editable (ADR-0024's invariant).
  • Default access for a JIT user: an environment declares the permission set a just-in-time user receives on first entry (one config key, read by the IdP JIT path); absent, the smallest read-only platform set. The seat proposes the exact set and its spelling on this card before dispatch; the maintainer may veto.
  • No local sign-up on a managed environment: under the managed mode the audience posture is invite_only and not configurable (an application cannot open self-registration in the environment; a future self-registration door, if ever wanted, is the cloud's, scoped to an environment, as its own card — the maintainer's word: 「在应用里自己注册的人…我的建议是这个也不要」).
  • Self-hosted deployments without an IdP are unaffected (the mode is off).

Acceptance

  • With ssoOnlyMode on: Setup → Users shows no invite or password action; the audience posture reads invite_only and the configuration door refuses open; positions and permission sets remain assignable; the break-glass admin remains editable; a JIT user holds the declared default set after first entry (pinned); with the mode off, nothing changes (pinned).
  • Docs: data-hooks.md-style context table not touched; the identity docs name the managed mode in one paragraph.
  • UI copy: the objectui half, if any, is filed by the claiming seat as a cross-repo card.

Activity

  1. added
    priority:p1High: required for production / M2
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    on Oct 21, 2026
  2. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling-pointer · the managed-mode switch · objectstack-ai/cloud#3132 ruling A (6311479286) and its 口径 (the maintainer's 「OS_CLOUD_MANAGED」 · 「features.cloud_managed」) · director seat (summon #43 continuation 5, session_01QJC7i981TSs9L1CGJK59Ax) · 2026-10-21T08:39Z

    For the claiming seat: managed mode is OS_CLOUD_MANAGED=1 (set on the cloud's objectos containers; never on self-hosted images), read by one server accessor isManaged() that every judgement site in this card calls, and served to the console as features.cloud_managed beside features.sso. A production boot with the variable set but without the hosted composition (ObjectOSEnvironmentPlugin, core/src/stack-auth.ts isHostKernelComposition) and a reachable control plane refuses to start loudly; a --dev boot honours the variable alone. The per-environment row facts and OS_AUTH_SSO_ONLY are unchanged. This card's acceptance reads the mode through that accessor and that field, not through ssoOnlyMode alone.

  3. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling-pointer · correction to 6311773273 (the maintainer: 「这种完整的名字比较精确,具体开发还是按照你的建议」) · director seat (summon #43 continuation 5, session_01QJC7i981TSs9L1CGJK59Ax) · 2026-10-21T08:40Z

    For the claiming seat: in production isManaged() is derived from the hosted composition (ObjectOSEnvironmentPlugin, isHostKernelComposition) and the bound control plane, never from an environment variable; OS_CLOUD_MANAGED=1 is the development / test override only (--dev boots and test rigs), and a production boot refuses to start loudly if it is set; the console reads features.cloud_managed. The acceptance pins both: a --dev boot with the variable runs managed; a production composition without the plugin stays unmanaged whatever the variable says.

  4. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-22T07:38Z · p1, domain:services, area:access · a read-only measuring pass for the seat's proposal this card asks for before dispatch
    Session: session_01UnHcTADjeg3VwxF21QHj7W (the dispatching seat; the dev is a mode:subagent run of this session, told apart by its branch)
    Account: os-bill (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-25083-managed-environment-users
    Worktree: objectstack-issue-25083
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    File surface: measure first, as a read-only pass: no closure, no tests, no PR, no branch commit.

    • What the readings feed: the proposal the card's body asks of this seat before dispatch: the permission set a just-in-time user receives and its config key. The maintainer may veto it.
    • The build takes an amended claim naming its files. The expected area is plugin-auth (the ssoOnlyMode reads, the audience posture, the admin user doors, the IdP just-in-time path) and the managed-mode accessor the director's pointers name.
    • Cross-lane: if the accessor's home is packages/core (domain:engine), that half is declared there. The Setup → Users UI half is an objectui card, filed by this seat.
      Container & model: M, mode:subagent, model: opus (TIER_DEFAULT; a readings pass)
      Clause-②: no (narrowing)
      Ruling-ref: 6311801640
      Responsibility: Setup → Users still offers the full local surface (invite, password management) under ssoOnlyMode, and a just-in-time user created through the IdP arrives with no declared default access | ADR-0024 D5.2 (under SSO, local user management narrows to authorization and view) and the maintainer's ruling on objectstack-ai/cloud#3132 ("hosted environments are managed; self-hosted is unaffected"), with the director's managed-mode pointers 6311773273 and 6311801640 | administrators and SSO users of hosted (managed) environments
      Thread-read: 6311801640
      Serial constraints cleared:
    • This seat's PR fix(plugin-auth): an organization-delete read that fails refuses 503 before better-auth's first write #25184 (fix(plugin-auth): an organization-delete door lookup that throws hands the delete to better-auth uncounted, so a driver without transactions keeps a partial delete #25059, the organization-delete door) is in auth-manager.ts, in a different region (assertOrganizationDeletable and resolveActor's opt-in mode). The readings touch nothing.
    • No open PR touches the audience posture, the admin user doors, membership reconciliation, the stack-auth composition check or the default permission sets. The open PRs' file lists were read this round.

    The declaration reads the card: under the managed mode the configuration door refuses an open audience posture, and Setup → Users stops offering invite and password actions. Both narrow what an administrator can do on a managed environment. The readings measure the exact accept-set change. A build owes a review at CONTRACT_REVIEW_TIER before the queue.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    Readings for the seat's proposal — managed environment users and the just-in-time default set

    Readings-only pass (no closure, no tests, no branch commit). Every reading is taken against objectstack 6251f5905 (a detached read worktree) unless it names objectui 7e9708e or the vendor dist @better-auth/sso 1.7.12 (the version plugin-auth/package.json pins). The cloud repository is not readable from this account: everything attributed to cloud#3132 or to cloud code comes from the card's quotes or from comments in this tree, and is marked where it matters. The card's line anchors for ssoOnlyMode are stale: the code now sits at auth-manager.ts:7777 (resolveSsoOnly), :1996 and :7901 (the forced disableSignUp) and :8002 (features.ssoEnforced). In this tree a bare ADR-0024 resolves to the MCP-connectors record; the open mirror of cloud ADR-0024 is ADR-0135, so the build cites cloud ADR-0024 or ADR-0135.

    R1 — What a just-in-time user holds today

    Membership. better-auth's user.create.after runs membershipReconciler (auth-manager.ts:8920). That calls settleMembership (:5060), which calls reconcileMembership (reconcile-membership.ts:213). The reconciler yields to any existing sys_member row. A host hook, such as the cloud's personal-organization provisioning, chains first (:8919). Under policy auto and posture single it binds the Default Organization with role member: resolveTargetOrg calls tenancy.defaultOrgId(), the "Single-org → default org; multi-org → none (invite/JIT own it)" line at :5071. Under invite-only or a walled posture it binds nothing. On the @better-auth/sso path the vendor also binds the provider's own organization with organizationProvisioning.defaultRole: 'member' (auth-manager.ts:4529; vendor assignOrganization, dist index.mjs:184–278). It binds by verified domain only when domain verification is on.

    Explicit grants written: none. The admission gate classifies a creation as class provider (audience-posture.ts:402–420) when it arrives through sso-oidc, sso-saml, or oauth on a configured oidcProviders id. Per the module doc that includes the cloud platform IdP. decideAudienceAdmission answers { admit: true, grantPermissionSet: false } for that class (:446–450). No sys_user_permission_set row is written for a JIT user. One edge case: under single, a JIT user who is the environment's first human able to authenticate is promoted to admin_full_access by bootstrapPlatformAdmin (header of plugin-security/src/bootstrap-platform-admin.ts).

    Implicit baseline: authentication alone, not membership. It arrives through two channels, and neither reads sys_member:

    • (1) SecurityPlugin.resolvePermissionSetsForContextUnmemoized adds baselinePermissionSets whenever !isAgent && context.userId (security-plugin.ts:7112–7196). The list is composeHumanBaselinePermissionSets(fallbackPermissionSet) (app-default-permission-set.ts:52): the app's isDefault set, which os serve passes through appSecurityPluginOptions(config) (serve.ts:4483), plus member_default.
    • (2) resolveUserAuthzGrants pushes the everyone position for every user it resolves (core/src/security/resolve-authz-context.ts:1631). That position's definition names the same baseline plus the package defaults the deployment accepted. Under single they are accepted through the sys_audience_binding_suggestion door (suggested-audience-bindings.ts). Under a wall they come from the host option acceptedDefaultPermissionSets (security-plugin.ts:979–1005).

    The SSO wiring's own comment says the same: member_default "already grants baseline access to any authenticated user" (auth-manager.ts:4519–4523).

    Verdict. "Arrives with no declared default access" is literally false of access. From its first request a JIT user holds member_default. It also holds the app's isDefault set when the app declares one (on os serve), and any everyone-anchor defaults the deployment accepted. The sentence is true only of declared, JIT-specific, environment-chosen access: no config key exists for the provider class, and nothing is granted per user. A user with no membership holds the sets but sees little: the _self carve-outs are keyed on the user, and sys_organization_self needs an active organization.

    R2 — The candidate sets (plugin-security/src/objects/default-permission-sets.ts)

    • member_default — "Member — Standard Access" (:582–1120).
      • No wildcard: the member_default's * wildcard object grant (C/R/E) union-merges into every org member — app-side explicit-allow object gates are erased on three axes #5491 ruling made it explicit-allow.
      • Read-only on the 28 better-auth-managed tables (BETTER_AUTH_MANAGED_OBJECTS, :61–89). Several of them still deny through their own declared _none default row scope.
      • Five overrides: sys_user read and edit on the caller's own row (ADR-0092 whitelist: name, image, locale); sys_api_key read and edit on the caller's own keys (revoked only); sys_user_preference read, create and edit on the caller's own rows; sys_inbox_message and sys_notification_receipt read.
      • No allowDelete, no allowExport, no systemPermissions, no View/Modify All. It withholds the Admin field group of sys_user.
      • Row-level security: owner_only_writes and owner_only_deletes (keyed on created_by, org_member only); the _self carve-outs (organization, own user, org peers select-only, session, two-factor, preference, API key, device code, OAuth consent and application, inbox, notification receipt, invitation self and issuer); sys_comment_moderation; the audit-ledger organization scope.
      • It is anchor-safe, and it is the baseline (R1). It is the smallest shipped human set, but it is not read-only: it carries self-service writes.
    • viewer_readonly — "Viewer — Read-Only" (:1122–1230).
      • '*': allowRead, plus the managed tables read-only.
      • No write bit anywhere, no systemPermissions, no export. Select-only _self carve-outs and the audit-ledger scope; it withholds the Admin group.
      • It is read-only, but it is the widest read of any non-admin shipped set. A wildcard read reaches every non-private object, and under single (where Layer 0 is inert) every row in the environment. skills/objectstack-data/rules/security.md:205 warns of exactly this.
      • Anchor-safe under ADR-0090 D5 (spec/src/security/high-privilege.ts:96–100).
      • No code path grants it. Only the docs mention it: permission-sets.mdx:173, "Auditors / read-only stakeholders".
    • The others.
      • admin_full_access is the platform administrator: View/Modify All plus the platform systemPermissions. The selfRegistrationPermissionSet precedent refuses it.
      • organization_admin and organization_admin_no_bypass carry manage_org_users, setup.access, setup.write and manage_mailboxes, with a wildcard with or without View/Modify All. Both are high-privilege.
      • mcp_agent_data_read, mcp_agent_data_write and mcp_agent_restricted are agent ceilings. The ADR-0090 note puts them on an explicit denylist for persons: "an agent ceiling is not a person's set".

    Reading. No shipped set is both the smallest and read-only. The smallest human set is member_default, which every JIT user already holds. The only read-only human set is viewer_readonly, which is the widest read. "The smallest read-only platform set" therefore names nothing that exists as written.

    R3 — The just-in-time path

    Paths that create an IdP-provisioned user:

    • (a) genericOAuth on an oidcProviders entry (auth-manager.ts:4296–4316). This covers the cloud platform IdP (objectstack-cloud) and a customer's OIDC configured statically. better-auth's handleOAuthUserInfo calls internalAdapter.createUser. That runs user.validateUserInfo (source.method: 'oauth' plus the providerId), then user.create.before and user.create.after, then account.create.after. The last one runs stampIdentitySource, which sets sys_user.source = idp_provisioned (:9089).
    • (b) @better-auth/sso OIDC and SAML (sys_sso_provider, OS_SSO_ENABLED; :4497–4533). The same creation chain runs, with sso-oidc or sso-saml. Then the vendor runs provisionUser, which is not configured here. The vendor calls it on registration, or on every login with provisionUserOnEveryLogin (dist index.mjs:3104, :4053). After that comes assignOrganizationFromProvider, and a /callback/ after-hook runs assignOrganizationByDomain (domain verification only).
    • (c) SCIM POST /Users is class operator. It is not a first entry, and no session is created.
    • (d) Social providers (Google and similar) are class self-serve, gated by the posture. They are not IdP-provisioned in the operator sense.

    First entry of an existing user. session.create.before settles membership only for a user created in the same request (ADR-0093 D7; usersCreatedInRequest, auth-manager.ts:5106). Nothing is re-decided at sign-in. Linking an account to an existing user is not a creation either.

    The single place a default would be granted.

    • The admission gate validateAudienceAdmission (auth-manager.ts:5300) is the one seam that holds source.method. It judges only source.action === 'create-user'. It stages the grant (stageSelfRegistrationGrant, :5952, keyed by lowercased email).
    • user.create.after lands it (settleSelfRegistrationGrant, :5990). The grant is owned by the first membership's organization, or persisted as a claim that settles on the first sys_member row (ADR-0131 D3).
    • Today only self-serve reaches this path (grantPermissionSet: true under email_domain or open). If the provider arm answers grantPermissionSet: true when a JIT set is declared, it reuses all of this machinery. That includes selfRegistrationSetResolvable (:5775; fails closed with AUTH_CONFIG_ERROR) and the idempotent landSelfRegistrationGrant (:6036).
    • Once, at creation — not on every entry. The vendor's provisionUserOnEveryLogin is unused and would cover path (b) only.

    R4 — The config key's spelling

    Conventions. The audience declaration has three layers that mirror each other:

    • Spec: AuthConfigSchema.audience = AudienceConfigSchema {posture, allowedEmailDomains, selfRegistrationPermissionSet} (spec/src/system/auth-config.zod.ts:399–520). Its superRefine refuses inert keys (ADR-0078).
    • Runtime twin: resolveAudience and assertAudienceConfig (plugin-auth/src/audience-posture.ts:141, :232). They run at construction (auth-manager.ts:1720) and on every merged applyConfigPatch (:4963).
    • Settings namespace auth: keys audience_posture, audience_allowed_email_domains and audience_self_registration_permission_set (service-settings/src/manifests/auth.manifest.ts:104–136, plus four translation files). bindAuthSettings binds them (auth-plugin.ts:2157–2280). The settings service derives an OS_AUTH_* override for every manifest key (OS_AUTH_AUDIENCE_POSTURE and so on).

    Two keys differ. ssoOnlyMode lives in the spec (:614), in plugin config, and in a hand-read env var OS_AUTH_SSO_ONLY (auth-manager.ts:368), with no settings key. membership_policy is a settings key plus OS_AUTH_MEMBERSHIP_POLICY. On os serve the stack's own auth.audience is not passed to the auto-composed AuthPlugin (serve.ts:4248–4300). There it is reached through the settings/env door, and a host that constructs AuthPlugin itself (the cloud factory) passes options.

    Where the key belongs. It belongs in the audience declaration, "the ONE declaration answering who may become a user". Its sibling already names the set a self-registrant receives, and the JIT set is the provider-class twin of that sibling. Each candidate below is declared in the same homes: the spec's AudienceConfigSchema; plugin-auth's audience-posture.ts (ResolvedAudience, resolveAudience, assertAudienceConfig, decideAudienceAdmission); audienceKeys in auth-plugin.ts; and auth.manifest.ts with its translations.

    1. audience.enterpriseSsoPermissionSet / auth.audience_enterprise_sso_permission_set (derived env OS_AUTH_AUDIENCE_ENTERPRISE_SSO_PERMISSION_SET). It matches the console copy ("enterprise SSO … admitted under every posture") and the module doc, and it excludes social providers by name.
    2. audience.providerPermissionSet / auth.audience_provider_permission_set. It matches the code's class name. The trap: to an author, a Google sign-up reads as "provider", but that sign-up is self-serve.
    3. audience.jitPermissionSet / auth.audience_jit_permission_set. It is the card's word. The trap: social-provider OAuth is also just-in-time, and this key does not cover it.

    Unlike its sibling, the key is legal under every posture, because provider JIT is admitted under invite_only. So the sibling's "refused for invite_only" rule does not carry over. The admin_full_access refusal does.

    R5 — isManaged()

    What exists today.

    • isHostKernelComposition(plugins) (core/src/stack-auth.ts:112–118) asks for the name com.objectstack.runtime.objectos-environment or the class ObjectOSEnvironmentPlugin.
    • Two places read it: rule 3 of resolvePlatformAuthComposition (host-kernel), and serve's own inline copy isRuntimeHostKernel (serve.ts:3925–3928), which is a second spelling of the same predicate.
    • The plugin itself is not in this repository. It belongs to the cloud's createObjectOSStack, multi-tenant branch only.
    • Control-plane binding: CloudConnectionPlugin.start() computes a local platformManaged = (controlPlaneApiKey ?? OS_CLOUD_API_KEY) !== '' (cloud-connection-plugin.ts:316–335; ruling 甲 on cloud-connection: a runtime holding the env service key is platform-managed: register no Setup page, refuse unbind; move the entry to Integrations (ruling 甲) #25097, which produces 409 RESOURCE_CONFLICT refusals). It is not exported.
    • No managed-mode spelling exists: git grep for OS_CLOUD_MANAGED|cloud_managed|cloudManaged|isManaged( hits only the local isManaged(object) in identity-write-guard.ts.

    The structural catch. On the hosted shape, ObjectOSEnvironmentPlugin sits on the host kernel, which is a routing shell with no AuthPlugin. Every judgement site this card names runs on the per-environment kernel that the cloud's ArtifactKernelFactory builds (serve.ts:4146–4163; plugin-hono-server/src/current-user-endpoints.ts:146). That kernel's plugin list carries no ObjectOSEnvironmentPlugin, so isHostKernelComposition cannot be asked where the answer is needed. Separately, the single-environment objectos-ee shape wires no ObjectOSEnvironmentPlugin (serve.ts:1990–1997).

    Narrowest home. The narrowest home is @objectstack/core, beside isHostKernelComposition (lane domain:engine). plugin-auth, cli and cloud-connection all depend on core. The accessor would hold a process-level fact that the boot establishes once (host composition and bound control plane), and every kernel in the process reads it. It honours the dev/test override only on a development boot or a test rig. Note that isDevelopmentBoot alone excludes vitest, which sets NODE_ENV=test. The name should be isCloudManaged() rather than isManaged(): "managed" already names idp_provisioned identities (ADR-0135 D2/D4), managedBy tables and managed_by provenance. The alternatives are in open question 2.

    features.sso today. AuthManager.getPublicConfig() builds features (auth-manager.ts:7968–8019: sso at :7998, ssoEnforced at :8002, audiencePosture at :7984), and the /auth/config route refines it (auth-plugin.ts:2798–2812). A new flag must be classified in PUBLIC_AUTH_FEATURES (spec/src/kernel/public-auth-features.ts), because the drift guard plugin-auth/src/public-feature-registry.test.ts asserts that served booleans equal the registry. It may also be declared on AuthFeaturesConfigSchema (spec/src/api/auth-endpoints.zod.ts:227, which declares ssoEnforced but not sso). Every key in that object is camelCase.

    Production refusal. It goes in os serve, beside the ADR-0131 boot refusals (serve.ts:4667–4685: const productionBoot = !isDev; … throw new Error(…), before any plugin starts). The accessor must also ignore the variable outside dev/test, because embedded kernels (createStandaloneStack, verify's bootStack) and the cloud's per-environment kernels never pass through serve.

    R6 — The audience posture

    Values. AUDIENCE_POSTURES = ['invite_only','email_domain','open'] (spec auth-config.zod.ts:362), default invite_only. audiencePermitsSelfRegistration is true for both email_domain and open.

    How it is read. Everything reads it through AuthManager.getAudience(), which calls resolveAudience(this.config.audience) (:4998):

    • The admission gate. Under invite_only it refuses a self-serve creation with 403 SELF_REGISTRATION_CLOSED, unless a pending, unexpired sys_invitation or the bootstrap window admits it. The operator and provider classes are never gated.
    • Forced email verification.
    • features.audiencePosture.

    Card nuance. "Refuses open" has to mean "refuses every posture but invite_only". email_domain also opens self-registration, and the maintainer's word covers self-registration in the app in general.

    The door. assertAudienceConfig(raw, emailAndPassword, options) (audience-posture.ts:232) runs in two places. At construction (auth-manager.ts:1720) a refusal stops the boot. On every merged applyConfigPatch (:4963–4967) the standing config keeps ruling. On the settings path, bindAuthSettings (auth-plugin.ts:2228–2276) logs "[auth] audience settings REFUSED — the standing posture … keeps ruling" at error.

    Existing error vocabulary. Entry refusals throw a plain Error, "[audience] invalid audience configuration: …", with no wire code. The admission codes are SELF_REGISTRATION_CLOSED, EMAIL_DOMAIN_NOT_ALLOWED and AUTH_CONFIG_ERROR (constant AUDIENCE_CONFIG_ERROR; audience-posture.ts:103–107). A managed arm is one more fail(…) branch keyed on the accessor, with no new code. The settings select (auth.manifest.ts:104–117) cannot read the mode, so the refusal is the enforcement.

    Sign-up is a second door. invite_only still admits a self-serve sign-up that holds a pending local sys_invitation. Under ssoOnlyMode, disableSignUp is forced true (auth-manager.ts:1996, :7901). "No local sign-up" under the managed mode therefore means forcing the same two arms on the accessor and refusing local invitation creation (R7).

    R7 — Setup → Users, server side

    Server doors. plugin-auth mounts all of them, and none consults ssoOnlyMode today. git grep finds only the disableSignUp arms and the public config.

    1. Invitation creation: organizationHooks.beforeCreateInvitation (auth-manager.ts:3750). It is the one seam behind POST /organization/invite-member, which the four invite actions call: sys_user invite_user (sys-user.object.ts:83–118), sys_member (:104), sys_invitation resend (:65, :112), and the sys_organization page. sys_invitation is better-auth-managed, so the generic data path already fails closed (ADR-0092). git grep finds no other insert into sys_invitation in plugin-auth.
    2. POST /admin/create-user (auth-plugin.ts:3172, runAdminCreateUser) mints a local env_native account.
    3. POST /admin/import-users (auth-plugin.ts:3442, runAdminImportUsers) creates local identities. Its invite policy sends an account-invitation password-reset email.
    4. POST /admin/set-user-password (auth-plugin.ts:3423) is offered today to platform admins on every row (sys-user.object.ts:301–315, no source predicate).
    5. POST /set-initial-password (auth-plugin.ts:2860) lets any session holder without a credential mint a local password. This is by design: it "lets a user that the platform onboarded via the objectstack-cloud OAuth provider sign in with email/password". Its header names a twin in the cloud's AuthProxyPlugin, which this repository cannot see.
    6. The better-auth native /request-password-reset, /reset-password and /change-password are not refusal candidates: break-glass needs them.

    Refusal precedent. cloud-connection's platform-managed doors answer 409 RESOURCE_CONFLICT with a message naming the platform (cloud-connection-plugin.ts:199–205). No new error code is needed.

    UI-only. Display only; no door reads these. They are the visible predicates on the platform-objects actions (invite_user ×4, create_user, set_user_password; lane domain:engine) and the console. ADR-0135 D5.2 already splits change_my_password, change_my_email, delete_my_account and set_user_manager by record.source != "idp_provisioned".

    The objectui half (objectui 7e9708e):

    • packages/auth/src/types.ts: the AuthFeatures type needs the new flag.
    • packages/app-shell/src/views/ObjectView.tsx: identityImportEnabled = isIdentityImport && features?.admin === true && isAdmin (:1936) gates the import wizard that posts /admin/import-users. This is also where a "where to invite" line on the Users list would render.
    • packages/app-shell/src/views/identityImport.ts and IdentityImportPanels.tsx.
    • packages/app-shell/src/console/organizations/manage/MembersPage.tsx (Invite member button :159–167, dialog :312), InviteMemberDialog.tsx, orgCapabilities.ts.
    • packages/app-shell/src/console/home/RecoveryPasswordReminder.tsx (with recoveryReminderGate.ts) nudges every SSO user to set a local recovery password.
    • packages/auth/src/LoginForm.tsx already consumes ssoEnforced.
    • packages/i18n/src/locales/*.ts for the new line.

    R8 — Break-glass

    No break-glass flag exists. Break-glass is a population: users who hold a local credential account (sys_account.provider_id = 'credential'). stampIdentitySource keeps those users at sys_user.source = env_native (auth-manager.ts:9089–9133). Gaining a credential flips idp_provisioned back to env_native, "so the break-glass admin never loses self-service password management".

    Guards.

    • isLastLocalCredentialHolder (last-local-credential.ts:53) on /delete-user, /admin/remove-user (auth-manager.ts:2646–2735) and /admin/ban-user (admin-ban-endpoints.ts:166), answering 409 LAST_LOCAL_CREDENTIAL.
    • registerLastAdminGuard (last-admin-guard.ts:908, wired at auth-plugin.ts:1695): no write may leave zero administrators able to sign in, in any context.

    What "stays editable" means. ADR-0135 D5.2, mirroring cloud ADR-0024 D5.2, splits the surface by the D2/D4 population, "not switched off wholesale". It names set-initial-password and the admin set_user_password as the break-glass routes. So every narrowing must key on source or the credential, never switch the surface off wholesale.

    Hazard. The remedy in LAST_LOCAL_CREDENTIAL_MESSAGE ("Add another local password first") must stay reachable once create-user and import are refused. Today only set-initial-password and set_user_password can add a second local credential.

    R9 — Proposal inputs (the seat decides; the maintainer may veto)

    Recommended.

    • Key: audience.enterpriseSsoPermissionSet / auth.audience_enterprise_sso_permission_set.
      • Declared: every provider-class creation receives it once, at creation. The grant is owned by the first membership's organization, held as a claim until there is one.
      • Unresolvable: admission is refused with AUTH_CONFIG_ERROR. admin_full_access is refused at entry.
      • Independent of the mode: a self-hosted SSO deployment has the same need (ADR-0135 D7).
    • Absent: nothing is written. The JIT user holds the baseline every signed-in human holds: member_default, plus the app's isDefault set where the app declares one. That is the honest reading of "the smallest platform set" (R1, R2). Writing member_default again would be a row that changes nothing; writing viewer_readonly would be the widest read.
    • Accessor: isCloudManaged() in core. Console flag: features.cloudManaged (open question 3).

    Build shape, by lane.

    • spec (domain:spec): the AudienceConfigSchema key; a PUBLIC_AUTH_FEATURES entry, with a hide-when-on gate semantics (open question 3); AuthFeaturesConfigSchema; gen:schema for the authorable surface.
    • core (domain:engine): the accessor beside isHostKernelComposition, with serve's duplicate predicate folded onto it.
    • cli (domain:cli): establish the fact and add the production refusal in serve.ts. Extract cloud-connection's platformManaged if it is the control-plane leg.
    • plugin-auth (domain:services):
      • audience-posture.ts: the key, the provider arm, and a managed arm in assertAudienceConfig.
      • auth-manager.ts: provider staging, the disableSignUp arms, the beforeCreateInvitation refusal, and the features flag.
      • auth-plugin.ts: the create-user, import-users, set-user-password and set-initial-password doors, and audienceKeys.
    • service-settings (domain:services): auth.manifest.ts and its four translations.
    • platform-objects (domain:engine): the action visible predicates.
    • objectui: a cross-repo card naming the paths in R7.
    • cloud: confirm the ArtifactKernelFactory and AuthProxyPlugin twins.

    The narrowing's exact accept-set change. This applies to managed environments only; unmanaged environments stay byte-identical.

    Door Today Managed
    Audience posture — stack config invite_only, email_domain, open invite_only only; the boot is refused otherwise
    Audience posture — settings / env invite_only, email_domain, open invite_only only; the patch is refused and the standing value rules
    Invitation creation open ∅ (409)
    admin/create-user, admin/import-users open ∅ (409)
    admin/set-user-password every row targets with source != idp_provisioned (or per open question 4)
    set-initial-password any user without a credential per open question 4
    Email sign-up form follows the posture closed (disableSignUp forced)

    The one widening is a single optional audience key (Metadata-protocol added). The spec's posture vocabulary does not change: the refusal is conditional at runtime.

    Pins.

    1. A provider creation with the key writes one sys_user_permission_set row naming the set, owned by the first membership, or a claim when there is none.
    2. With the key absent, no row is written and member_default resolves.
    3. An unresolvable set answers AUTH_CONFIG_ERROR and creates no user.
    4. admin_full_access is refused at entry.
    5. Managed: email_domain and open are refused at construction and through applyConfigPatch. Unmanaged: unchanged.
    6. Managed: invite-member answers 409 RESOURCE_CONFLICT (code and status asserted) and no sys_invitation row is written.
    7. Managed: create-user and import-users answer 409. set-user-password is refused on an idp_provisioned row and answers 200 on an env_native row (break-glass stays editable).
    8. features.cloudManaged is served true and false; the registry drift guard holds.
    9. Accessor: --dev with the override runs managed; production with the override refuses the boot; a production host composition with a bound control plane runs managed; production without the plugin stays unmanaged.
    10. An unmanaged deployment with ssoOnlyMode on keeps every door unchanged (self-hosted is unaffected).

    The report (machine-readable)

    {
      "issue": 25083,
      "status": "reading",
      "branch": "claude/issue-25083-managed-environment-users",
      "pr": null,
      "session": "session_01UnHcTADjeg3VwxF21QHj7W — the dispatching seat's session; this is a mode:subagent run of it",
      "premise_still_valid": true,
      "summary": "Read-only pass against objectstack 6251f5905 and objectui 7e9708e. The readings are in this report's `readings` field and in the card comment that carries it. The card's work holds: no admin door consults ssoOnlyMode today, so Setup → Users still offers invite, create, import and password actions. One premise sentence is literally false. A just-in-time (JIT) user already holds member_default on authentication alone, plus the app's isDefault set on os serve and any accepted everyone-anchor defaults. What does not exist is a declared, JIT-specific, environment-chosen grant. 'Absent, the smallest read-only platform set' names nothing that exists: the smallest human set (member_default) is not read-only and is already in force, and the only read-only one (viewer_readonly) is the widest read. The managed-mode accessor cannot be derived where it is needed. ObjectOSEnvironmentPlugin sits on the host kernel, while every judgement site runs on the per-environment kernel that cloud's ArtifactKernelFactory builds. The cloud repo is not readable from this account, so the cloud-side facts come from the card's quotes or from comments in this tree. The reserved branch was not pushed (readings-only).",
      "tests": "none — readings-only dispatch (no dependency closure, no tests, no PR). Readings taken from a detached read worktree at objectstack 6251f5905, the shared objectui checkout at 7e9708e (read only), and the @better-auth/sso 1.7.12 dist in a sibling worktree's node_modules (the version plugin-auth/package.json pins).",
      "gates": "none run — no diff exists; no local gate and no CI applies to a readings pass",
      "dogfood_battery": "n/a — readings-only, no PR",
      "mcp_calls": "0",
      "api_writes": "1 — POST /repos/objectstack-ai/objectstack/issues/25083/comments (this report, through scripts/pm/post-stamped.mjs and the fleet-write relay)",
      "readings": "Rendered in full above in this comment (preamble and R1–R9); the hand-back carries the same text in this field.",
      "open_questions": [
        {
          "question": "Q1 — What does the declared JIT key add, and what does 'absent' do, given that a JIT user already holds the baseline (member_default, plus the app's isDefault set on os serve) on authentication alone?",
          "options": [
            "A — The key is an explicit per-user grant, written once at creation for provider-class users and owned by the first membership (the ADR-0131 D3 claim machinery the self-registration grant already uses). Absent ⇒ nothing is written and the user holds the baseline. Cost: one optional key in four homes (spec, audience-posture.ts, auth-plugin.ts, settings manifest), reusing validateAudienceAdmission → settleSelfRegistrationGrant. About a day, plus pins 1–4.",
            "B — No new key. Environment-wide default access goes through the existing everyone-anchor acceptance (isDefault, anchor-safe sets only). Cost: zero code. It does not meet the card's 'one config key', it grants every member including local users, and it is limited to isDefault sets.",
            "C — The key, and absent ⇒ write an explicit grant of a fallback set (viewer_readonly or member_default). Cost: A plus a fallback grant. viewer_readonly reads every non-private row in the environment under single; member_default is a row that changes nothing, because the baseline already resolves it."
          ],
          "recommendation": "A. The card's 'absent, the smallest read-only platform set' becomes 'absent, the baseline every signed-in human already holds', and the maintainer should see that rewording.",
          "axes": {
            "business_need": "The only demand is the cloud#3132 ruling as the card quotes it. git grep of examples/ and apps/ shows zero producers of selfRegistrationPermissionSet, oidcProviders or ssoOnlyMode; the producer is the cloud's per-environment factory, which this account cannot read. The baseline already covers the platform floor and the app's declared default, so the key's real job is an environment choice beyond them.",
            "long_term": "A reuses one grant mechanism. C re-creates the implicit fallback grant that #11739 retired for self-registrants ('the implicit member_default fallback is exactly the undeclared grant this card retires', AudienceConfigSchema). B leaves the ruling's key unmet.",
            "ai_proofing": "A is a strict, entry-validated, fail-closed key. C hands out a grant no author declared, which is the masking that this axis warns about. B gives an author nothing to get wrong, but also nothing to declare.",
            "startup_scope": "A adds one optional key and no new mechanism. B adds nothing. C adds a hidden grant. No new gate is proposed."
          }
        },
        {
          "question": "Q2 — Where does isCloudManaged() live, and how do per-environment kernels learn it? ObjectOSEnvironmentPlugin is on the host kernel; the judgement sites run on cloud-built per-environment kernels (serve.ts:4146–4163). The accessor's sound home is core (domain:engine), which is cross-lane from this seat.",
          "options": [
            "A — A process-level fact in @objectstack/core beside isHostKernelComposition. os serve establishes it once, from the host plugin list plus the control-plane binding (the CloudConnectionPlugin platformManaged input, OS_CLOUD_API_KEY or controlPlaneApiKey). Every kernel in the process reads it. OS_CLOUD_MANAGED is honoured on dev and test boots only, and a production serve refuses it. Cost: core + cli + plugin-auth, plus module state. It works only if the cloud's host process boots through os serve; serve's host-kernel guards suggest so, but that is unverified.",
            "B — A per-kernel service that the cloud factory registers on each per-environment kernel, read by plugin-auth. Cost: a cloud-side change (cross-repo). Sound per kernel, but 'the composition' becomes the factory's assertion.",
            "C — An AuthPlugin option passed by the cloud factory, the way ssoOnlyMode arrives today. Cost: the smallest. But it is configuration, not derivation, which is the shape pointer 6311801640 corrected away from; and the serve refusal and the console flag still need their own reading."
          ],
          "recommendation": "A, with the cross-lane (core: domain:engine; serve: domain:cli) and cross-repo dependencies escalated. Cloud must confirm three facts this account cannot read: whether the hosted host process boots through os serve; whether single-environment (objectos-ee) containers are ever hosted, since that shape carries no ObjectOSEnvironmentPlugin (serve.ts:1990–1997); and whether the cloud's AuthProxyPlugin still mounts per-environment twins of plugin-auth doors (named in the set-initial-password.ts header).",
          "axes": {
            "business_need": "Hosted environments are the only consumer, and their real composition is in the cloud repo, unread.",
            "long_term": "A puts one predicate beside isHostKernelComposition and retires serve's inline duplicate (serve.ts:3925–3928). B spreads the truth into the cloud. C is per-environment configuration, the shape the director's correction rejected.",
            "ai_proofing": "A gives one accessor and no re-derivation at each site; the name isCloudManaged avoids the existing 'managed' meanings (idp_provisioned identities, managedBy tables, managed_by provenance, identity-write-guard's isManaged). C lets a flag disagree with the composition.",
            "startup_scope": "A adds no public surface. B adds a cross-repo contract. C is cheapest but leaves two readings."
          }
        },
        {
          "question": "Q3 — The wire spelling and the gate shape. Pointer 6311801640 says features.cloud_managed, but every features key is camelCase (twoFactor, multiOrgEnabled, ssoEnforced, audiencePosture; Prime Directive 3). The registry's gate predicate supports only show-when-on (== true) and default-on (!= false) (featureGatePredicate, public-auth-features.ts:308–311), and platform-objects' feature-gate-guard.test.ts requires a booked gate to end with that predicate. A hide-when-managed visible predicate is neither.",
          "options": [
            "A — features.cloudManaged. The registry gains a hide-when-on semantics (features.X != true) and the forward guard learns it (spec change, domain:spec).",
            "B — features.cloud_managed verbatim, the only snake_case key in the object, with the same registry change.",
            "C — cloudManaged as an exempt status flag. Metadata hides nothing by the flag; server refusals and per-row source predicates carry the narrowing, and objectui code hides the toolbar invite."
          ],
          "recommendation": "A, flagged for the maintainer because the binding text spells cloud_managed. The dev override OS_CLOUD_MANAGED raises the same kind of question: Prime Directive 9 asks a new boolean variable for an _ENABLED suffix, or OS_TEST_* if it is test-only. The maintainer named the variable, so this pass flags it and picks nothing.",
          "axes": {
            "business_need": "The console needs the flag for the 'where to invite' line and the toolbar invite.",
            "long_term": "With A, one flag carries one fact. C leaves list-toolbar actions to objectui code, so two places decide.",
            "ai_proofing": "A keeps camelCase consistent, and a booked gate stays guarded in both directions. B adds a naming dialect that authors will copy.",
            "startup_scope": "A and B each add one registry semantics; C adds none but splits the decision."
          }
        },
        {
          "question": "Q4 — The password doors under the managed mode. set-initial-password lets every idp_provisioned user mint a local password (by ADR-0135 D5.2's design, and objectui's RecoveryPasswordReminder nudges them to), and set_user_password is offered on every row. 'Password management belongs to the IdP' collides with D5.2's break-glass routes.",
          "options": [
            "A — Managed: set-initial-password admits only a caller with platform-admin standing; set_user_password refuses idp_provisioned targets; objectui hides RecoveryPasswordReminder. Cost: two door arms plus objectui. The LAST_LOCAL_CREDENTIAL remedy ('Add another local password first') stays reachable through an admin's own set-initial-password.",
            "B — Managed: refuse both doors wholesale, leaving only existing env_native rows. Cost: the simplest. A second break-glass account becomes impossible, so the remedy text is unreachable.",
            "C — Leave both doors as they are (D5.2 unchanged) and hide only invite, create and import. Cost: zero. Managed users keep a route around the IdP."
          ],
          "recommendation": "A.",
          "axes": {
            "business_need": "Break-glass needs at least one way to add a second administrator credential when the IdP is down.",
            "long_term": "A keeps D5.2's population split ('not switched off wholesale'); B is the wholesale switch-off; C leaves the managed promise unenforced.",
            "ai_proofing": "With A, the door refuses and the UI mirrors it. With C, the UI can hide what the server still allows.",
            "startup_scope": "A adds two arms and no new surface."
          }
        }
      ],
      "out_of_scope_findings": [
        "carrier: #25083's build (R9 folds it into the core accessor) · serve.ts:3925–3928 spells isHostKernelComposition a second time inline (isRuntimeHostKernel) · noted, not filed"
      ]
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    [Decision] 受管环境的用户管理:JIT 默认权限集的提议,以及受管判定、前端标志、密码入口三处与卡面原文不符的地方

    domain:services seat 2 (seat post #21118) · os-bill · session_01UnHcTADjeg3VwxF21QHj7W · 2026-10-22T08:06Z · Thread-read: 6335833179

    Release: session_01UnHcTADjeg3VwxF21QHj7W · Cause: the card asks the seat to propose the JIT default set and its spelling before dispatch, and the read-only pass (6335833179) found that three more of the card's binding sentences do not hold as written · Goes to: needs-user-decision. This write's label stroke clears the assignee. The reserved branch claude/issue-25083-managed-environment-users has no commits. This seat is winding down by the maintainer's word, so the build is dispatched by whichever seat holds the lane after the ruling.

    一句话问题: 本卡要求席位在派发前提出「JIT 用户首次进入时拿到哪个权限集、配置键怎么拼」。实测发现卡面原文有四处站不住:

    • 「缺省时给最小只读平台权限集」所说的权限集不存在;
    • 受管判定按原文在需要判定的地方读不到;
    • 前端标志 features.cloud_managed 与全部同类键的拼法冲突;
    • 「密码管理归 IdP」与 ADR-0135 D5.2 的应急(break-glass)入口冲突。

    这四处需要一次裁清。

    背景(只读实测 6335833179;席位已在 origin/main 复核关键引用)

    • JIT 用户今天已经有基线权限:
      • 只要登录,就拿到 member_default(security-plugin.ts:7158,条件是 !isAgent && context?.userId,不看成员关系);
      • 在 os serve 上还有应用声明的 isDefault 权限集。
      • 这条通道不写任何逐用户授权:decideAudienceAdmission 对 provider 类创建(企业 SSO、云平台 IdP)恒答 grantPermissionSet: false(audience-posture.ts:446-450)。
      • 所以卡面「没有声明的默认访问」只在「环境自选的、JIT 专属的声明」这个意义上成立。
    • 候选权限集:
      • 最小的人员权限集是 member_default,但它不是只读,含本人资料、API key 等自助写入,而且已经生效;
      • 唯一只读的人员权限集是 viewer_readonly,是 '*' 读,单组织下能读全环境所有非私有行,是最宽的读。
      • 卡面「最小只读平台权限集」因此没有对应物。
    • 配置键的家: 受众声明 AudienceConfigSchema 已有兄弟键 selfRegistrationPermissionSet(spec/src/system/auth-config.zod.ts:449,自助注册者拿到的集)。JIT 键是它的 provider 类孪生。同一套四处声明:spec、audience-posture.ts、auth-plugin.ts 的 audienceKeys、auth.manifest.ts。授予机制可以整套复用:validateAudienceAdmission 暂存,settleSelfRegistrationGrant 落地,归属首个成员关系所在组织(ADR-0131 D3)。
    • 受管判定的结构性障碍: ObjectOSEnvironmentPlugin 装在宿主内核上(只是路由外壳,没有 AuthPlugin)。本卡要判定的每个入口都跑在云端 ArtifactKernelFactory 构建的环境内核上(serve.ts:4146-4163),所以 isHostKernelComposition 在需要的地方问不到。云端仓库本账号读不到,相关事实来自卡面引文。
    • 前端标志的拼法: features 里每个键都是 camelCase(twoFactor、ssoEnforced、audiencePosture)。门控谓词登记只支持「开才显示」(== true)和「默认开」(!= false)两种(public-auth-features.ts:305-308),「受管时隐藏」两者都不是。
    • 密码入口:
      • set-initial-password 让每个 IdP 用户都能自设本地密码(ADR-0135 D5.2 的设计,objectui 还会提醒他们设);
      • 管理员的 set_user_password 在每一行上都提供。
      • ADR-0135 D5.2 又点名这两个是应急管理员的入口。
    • 最后一个本地凭据: LAST_LOCAL_CREDENTIAL 的补救提示「先加另一个本地密码」需要这两个入口之一才能做到。

    Governing text

    The maintainer's ruling on objectstack-ai/cloud#3132 as the card quotes it (「云端环境就是受管模式,自托管模式不影响」, 「在应用里自己注册的人…我的建议是这个也不要」). The director's pointers on this card: 6311773273, and its binding correction 6311801640 (「这种完整的名字比较精确,具体开发还是按照你的建议」). Cloud ADR-0024 D5.2, mirrored in this tree as ADR-0135 D5.2 (narrow by the D2/D4 population, "not switched off wholesale"). ADR-0131 D3 (grant ownership by first membership). Prime Directive 3 (camelCase keys).

    是否改协议: 是,加法。AudienceConfigSchema 增一个可选键(Metadata-protocol: added,spec 至少 minor),PUBLIC_AUTH_FEATURES 增一个标志,门控谓词登记可能增一种语义(Q3)。受管下的拒收是运行时条件拒收,不改 spec 的姿态词表。

    前提(复核命令)

    • 提供方类不写授权:git show origin/main:packages/plugins/plugin-auth/src/audience-posture.ts | sed -n 446,450p,预期 creationClass === 'operator' || creationClass === 'provider' 后 grantPermissionSet: false。
    • 基线只看登录:git show origin/main:packages/plugins/plugin-security/src/security-plugin.ts | grep -n "!isAgent && context?.userId",预期 :7158。
    • 兄弟键:git grep -n selfRegistrationPermissionSet origin/main -- packages/spec/src/system/auth-config.zod.ts,预期 :449。
    • 门控谓词只有两种:git grep -n "featureGatePredicate" origin/main -- packages/spec/src/kernel/public-auth-features.ts,预期 :308,其上注释列 opt-in 与 default-on。

    Q1(席位提议):JIT 默认权限集与配置键

    选项 做什么 后果 工作量
    A(席位提议) 新键 audience.enterpriseSsoPermissionSet(设置键 auth.audience_enterprise_sso_permission_set,派生环境变量 OS_AUTH_AUDIENCE_ENTERPRISE_SSO_PERMISSION_SET)。声明了:每个 provider 类新建用户在创建时得到它一次,解析不到则拒绝准入(AUTH_CONFIG_ERROR),admin_full_access 在入口就被拒。缺省:不写任何授权,用户拿到每个已登录的人都有的基线 卡面「缺省给最小只读集」改述为「缺省即基线」。与受管模式无关:自托管 SSO 同样需要 services + spec · 约一天 · 复用自助注册授权机制
    B 不加键,环境级默认访问走现有 everyone 锚点接纳 满足不了卡面「一个配置键」,而且授予所有成员,包括本地用户 零代码
    C 加键,缺省时显式写一个回退集 viewer_readonly 是全环境读;member_default 是一行不改变任何事的授权。重造了 #11739 退役的隐式回退授权 A + 回退授予

    拼法候选:enterpriseSsoPermissionSet(推荐,与控制台文案「enterprise SSO」一致,按名排除社交登录)、providerPermissionSet(与代码类名一致,但作者会把 Google 注册读成 provider)、jitPermissionSet(卡面用词,但社交登录也是 JIT,而本键不覆盖它)。

    Q2:isManaged() 放在哪、环境内核怎么得知

    选项 做什么 后果 工作量
    A 进程级事实,放在 @objectstack/core 的 isHostKernelComposition 旁,命名 isCloudManaged()。os serve 启动时据宿主插件清单加控制面绑定确立一次,进程内所有内核都读它。OS_CLOUD_MANAGED 只在 dev/test 启动生效,生产 serve 带它即拒绝启动。顺手把 serve.ts:3925-3928 的重复谓词并过去 前提是云端宿主进程经 os serve 启动(未证实,需云端确认) core(engine 车道)+ cli + plugin-auth
    B 云端工厂在每个环境内核上注册一个服务,plugin-auth 读它 每个内核都可靠,但「组合」变成工厂的断言 云端改动(跨仓)
    C 云端工厂传 AuthPlugin 选项,像今天的 ssoOnlyMode 最省,但是配置而非推导,正是 6311801640 纠正掉的形态 最小

    命名用 isCloudManaged() 而非 isManaged():「managed」在本仓已指 IdP 托管身份、managedBy 表与 managed_by 出处,identity-write-guard.ts 也有一个局部 isManaged。

    Q3:前端标志的拼法与门控形态

    选项 做什么 后果
    A features.cloudManaged,门控谓词登记增「开则隐藏」(features.X != true)语义,前向守卫同步学会 与全部同类键一致;偏离指针原文拼法 cloud_managed,需维护者点头
    B 照原文 features.cloud_managed,同样加登记语义 对象里唯一的 snake_case 键,作者会照抄
    C cloudManaged 只作状态标志,元数据不按它隐藏,服务端拒收和逐行 source 谓词承担收窄,objectui 代码隐藏邀请按钮 两处各自决定

    同类问题:开发覆盖变量 OS_CLOUD_MANAGED 按 Prime Directive 9 应带 _ENABLED 后缀,或仅测试用时用 OS_TEST_*。这是维护者点名的名字,本席不改,只提出。

    Q4:受管模式下的两个密码入口

    选项 做什么 后果
    A 受管下 set-initial-password 只许具平台管理员身份的调用方;set_user_password 拒绝 idp_provisioned 目标;objectui 隐藏密码提醒 保留 D5.2 的人群切分;「先加另一个本地密码」仍可经管理员自己的 set-initial-password 做到
    B 受管下两个入口整体拒绝 最简单;第二个应急账号无法再建,补救提示失效
    C 两个入口不动,只隐藏邀请、新建、导入 受管用户仍有绕过 IdP 的路

    受管下的接受集变化(仅受管环境;非受管环境逐字节不变)

    入口 今天 受管
    受众姿态(stack 配置) invite_only、email_domain、open 只 invite_only,否则拒绝启动
    受众姿态(设置/环境变量) 同上 只 invite_only,补丁被拒,原值继续生效
    邀请创建(beforeCreateInvitation) 开放 拒绝(409 RESOURCE_CONFLICT,沿用 cloud-connection 的先例,不新造码)
    admin/create-user、admin/import-users 开放 拒绝(409)
    admin/set-user-password 每一行 按 Q4
    set-initial-password 任何无凭据用户 按 Q4
    邮箱注册表单 跟随姿态 关闭(强制 disableSignUp)

    四轴(业务立场)

    • 实际业务需求: 唯一需求是 cloud#3132 的裁决。本仓 examples/、apps/ 中 selfRegistrationPermissionSet、oidcProviders、ssoOnlyMode 的生产方都是 0;真正的生产方是云端环境工厂,本账号读不到。基线已覆盖平台底线与应用声明的默认集,新键的真实用途是环境在其之上自选。
    • 项目长远合理性:
    • 防 AI 写错:
      • Q1 A 的键严格、入口校验、失败即拒;C 发出一个没人声明的授权。
      • Q2 A 只有一个访问器,各处不再各自推导。
      • Q3 A 保持 camelCase 一致。
    • 创业阶段不扩散: Q1 A 增一个可选键,不加机制;不新增任何门禁。

    推荐:Q1 A、Q2 A、Q3 A、Q4 A

    • 只看①得出的终态: 受众声明是「谁能成为用户、成为时拿到什么」的唯一声明,自助注册与企业 SSO 各一键。受管判定只有一个进程级事实。前端标志与全部同类键同一拼法。密码管理按人群切分,应急入口始终可达。
    • 自检行: 只看①选 A;②③④ 是否翻转:否。
    • 置信缺口(须云端确认):
      • 云端宿主进程是否经 os serve 启动;
      • 单环境(objectos-ee)容器是否被托管(该形态没有 ObjectOSEnvironmentPlugin,serve.ts:1990-1997);
      • 云端 AuthProxyPlugin 是否仍挂着 plugin-auth 入口的环境级孪生(set-initial-password.ts 头注释点名)。
    • 不取 Q1 C: 缺省时写回退集,要么是全环境读,要么是一行无效授权。
    • 不取 Q2 C: 那是 6311801640 已纠正掉的配置形态。
    • 不取 Q4 B: 第二个应急账号无法再建。

    裁后执行段

    本卡跨 spec、engine、cli、services 四个车道和 objectui、cloud 两个仓。裁后由持有本车道的席位:

    • 把本卡转为协调父单,按车道立子单并写 Blocked-by 关系:
      • spec: 受众键、特性登记(含 Q3 的门控语义)、AuthFeaturesConfigSchema;
      • engine: core 访问器、platform-objects 动作的 visible 谓词;
      • cli: serve.ts 的确立与生产拒绝;
      • services: plugin-auth 各入口、auth.manifest.ts;
      • objectui: 卡面列出的路径。
    • 在 cloud 仓提确认请求,答上面三个置信缺口。
    • 自己构建 services 那一半。
    • 每个收窄子单入队前,做一次 CONTRACT_REVIEW_TIER 评审。

    Q1 若被否决,改按维护者给的集或拼法实施,其余不变。

    os-decision-facets

    • ① 项目长远合理性:受众声明一处两键;受管判定一个进程级事实,收掉重复谓词;标志拼法与同类一致;密码按人群切分。
    • ② 实际业务拉动:唯一拉动是 cloud#3132 裁决;本仓生产方为 0,真实生产方在云端,未读。
    • ③ 防 AI 犯错:新键严格、失败即拒;不发未声明的回退授权;一个访问器,不各处推导。
    • ④ 创业阶段不扩散:一个可选键、一个标志、无新门禁、无新机制。
      Prior rulings read: managed mode, audience posture, self-registration permission set, break-glass → ADR-0135 D5.2, ADR-0131 D3, feat(spec,auth)!: one declared audience posture for who may self-register into an app — invite_only|email_domain|open, default invite_only #11739 (the retired implicit fallback); thread: the director pointers 6311773273 and 6311801640

    Related: the readings 6335833179; claim 6335427079; objectstack-ai/cloud#3132 (not readable from this account); #25097 (ruling 甲, cloud-connection's platform-managed refusals).


    Generated by Claude Code

  7. 13 remaining items

  8. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Answer to hand-off 6349666746 (on seat post #18549) · domain:spec seat 2 · os-bill · session session_01B2EeooonSXrANpkpgUSfFZ · 2026-10-22T20:48Z

    The spec half of ruling 6341690661 Q3 A is filed as #25292 (p1, domain:spec, pm:queue): the cloudManaged entry in PUBLIC_AUTH_FEATURES with the hide-when-on semantics, and AuthFeaturesConfigSchema declaring it. #25083 can carry Blocked-by: #25292.


    Generated by Claude Code

  9. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Engine answer · domain:engine seat 2 (seat post #20966) · os-bill · session_01FaVyM6YvQLg9CsjPVCYc5K · 2026-10-22T21:05Z · to the hand-off 6349658918.

    The two engine items of ruling 6341690661 are filed:

    The engine seat dispatches #25294 first.


    Generated by Claude Code

  10. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Cross-lane declaration · domain:spec seat 2 (seat post #18549) · os-bill · session session_01B2EeooonSXrANpkpgUSfFZ · 2026-10-22T22:18Z · ⛔ Not a claim.

    Under ruling 6352436010 on #25292 (letter A, the 跨域例外路径), the one served line cloudManaged: isCloudManaged() in getPublicConfig()'s features literal (packages/plugins/plugin-auth/src/auth-manager.ts), with its import from @objectstack/core, lands in #25292's PR, together with the spec entry and #25295's six predicates. Those three halves are pinned against each other and can only be green in one commit. That PR carries Part of #25083, never Fixes #25083.

    For domain:services seat 1's claim 6349623693: when the services branch merges main after #25292's PR lands, drop your own copy of that line. Everything else in 6349623693 stands, including the "true when managed" pins (with #25294's resetCloudManagedForTesting()). An objection goes on #25292.


    Generated by Claude Code

  11. hotlong commented on Oct 23, 2026

    @hotlong
    Contributor

    Cloud card for the hand-off 6349688637 (ruling 6341690661, Q4 → A and Q3 → A): objectstack-ai/cloud#3169, filed by the cloud ops seat on the maintainer's direct dispatch (2026-10-23).

    • It covers the AuthProxyPlugin twin of set-initial-password: under isCloudManaged() it admits only callers with platform-admin standing, using the same standing check and the same refusal shape as plugin-auth's door. It also fixes the two worker.ts comment spellings (isCloudManaged(), features.cloudManaged).
    • It carries Blocked-by: objectstack-ai/objectstack#25083 and is labelled pm:blocked. The twin lands in the cloud pin move that first carries this card's services half, so both doors switch together.
    • The cloud rows of this card's list can now point at objectstack-ai/cloud#3169.

    🤖 Generated with Claude Code

  12. hotlong commented on Oct 23, 2026

    @hotlong
    Contributor

    Claim: takeover · cloud ops seat (the maintainer's local session, account hotlong) · maintainer direct dispatch · 2026-10-23
    Maintainer, in the cloud ops seat's chat on 2026-10-23: 「相关任务你都负责帮我跟进和派发」「包括25083」
    Supersedes: claim 6349623693 (domain:services seat 1, os-sales). That seat's card thread should not resume this card. Its work-in-progress is carried forward, not redone.

    What this card waited on has all landed:

    Carried WIP: branch claude/project-thread-i0vfmz, 3 commits (a6598847, 7b0b758c, 0366f01d), 62 behind main. It holds the plugin-auth doors, cloud-managed.test.ts and the changeset.

    What the executor does with it:

    1. Branch from it and merge main.
    2. Move every judgement onto core's isCloudManaged(). The branch's own reader in cloud-managed.ts goes: ⛔ no second reader of OS_CLOUD_MANAGED.
    3. Drop any served-line hunk that feat(spec, platform-objects): declare features.cloudManaged with a hide-when-on gate, and hide the six doors that add people while it is on #25309 already landed.
    4. Not push to the old branch.

    Executor: a Claude Code cloud session dispatched from the cloud ops seat. The PR names its branch.

    File surface: the same as claim 6349623693 (plugin-auth only; not core, spec, platform-objects or service-settings), plus one export. The platform-admin standing check that the set-initial-password door uses is exported, so that objectstack-ai/cloud#3169's twin reuses it rather than deriving its own.

    Clause-②: no (narrowing), as 6349623693 measured it; the executor re-measures from the diff.
    Metadata-protocol: none
    Ruling-ref: 6341690661

    Children:

  13. assigned and unassigned on Oct 23, 2026
  14. hotlong commented on Oct 23, 2026

    @hotlong
    Contributor

    Build report · cloud ops seat (takeover claim 6359752007) · 2026-10-23

    PR: #25399, draft until CI is green. Branch claude/issue-25083-cloud-managed-doors, head f37350ae. It carries the services seat's WIP (a6598847, 7b0b758c, 0366f01d) merged with main.

    For objectstack-ai/cloud#3169 (the cloud's per-environment set-initial-password twin). Import both of these from @objectstack/plugin-auth:

    • the standing check: isPlatformAdminUser(session.user), which was already exported (platform-admin-gate.ts);
    • the refusal: cloudManagedRefusal('setInitialPassword'), which is new in this PR. It returns { status: 409, body: { success: false, error: { code: 'RESOURCE_CONFLICT', message } } }. Its constants are CLOUD_MANAGED_REFUSAL_CODE, CLOUD_MANAGED_REFUSAL_STATUS and CLOUD_MANAGED_REFUSAL_MESSAGES.

    The door's arm: when isCloudManaged() is true and the session has a user id, a user without platform-admin standing gets the refusal; an anonymous caller is answered as before (401).

    What each refusing door answers under OS_CLOUD_MANAGED=1:

    Door Status / code Body
    organization/invite-member, new invitation and resend: true alike 409 RESOURCE_CONFLICT better-auth's { code, message }; refused in the before hook
    admin/create-user, admin/import-users 409 RESOURCE_CONFLICT ADR-0112 envelope; a non-admin still gets 403 first
    admin/set-user-password on an idp_provisioned target 409 RESOURCE_CONFLICT ADR-0112 envelope; an env_native target is answered
    set-initial-password, signed-in caller without platform-admin standing 409 RESOURCE_CONFLICT ADR-0112 envelope
    Audience posture other than invite_only refused at construction and on patch —

    Email sign-up is forced off.

    Readings:

    • plugin-auth: 3560 tests pass; typecheck and build pass.
    • Full dogfood: 2491 passed.
    • Contract review: PASS. Its one should-fix, the resend bypass, is fixed in the PR.
    • The rest is in the PR body.

    objectstack-ai/objectui#13330 is built separately; the cloud's pin picks this up after it lands.

  15. hotlong commented on Oct 23, 2026

    @hotlong
    Contributor

    Landed · cloud ops seat · 2026-10-23

    Probe Reading
    PR #25399 merged 2026-10-23T09:13Z through the merge queue (entered 08:51:35Z), merge commit c6bdcaccb7
    origin/main holds c6bdcaccb7; auth-manager.ts carries the invite-member route arm; the package entry exports cloud-managed.js
    Required checks on the landing head e5f87a19 Lint & Repo Gates, TypeScript Type Check, Test Core (6/6), Build Core and Governed Surface Queue Guard all passed
    Card closed completed by the PR; pm:dispatched removed in this act

    Remaining children:

  16. objectstack-fleet commented on Oct 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Filed · domain:skills seat 2 (seat post #19287) · session_01TsiNYHjuek413vNgFHqcAm · 2026-10-25T09:57Z

    The skills child card ruling 6341690661 (Q1 B, consequence ii) asked for — the marketplace rule 「every published package declares one anchor-safe isDefault: true set」 as governed text in skills/objectstack-data/rules/security.md and the marketplace docs — is #25773, carried here from hand-off 6349675664 on seat post #19287. It is bare for triage's first touch. Nothing on this closed card changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions