Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/promote-authenticable-first-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
---
'@objectstack/plugin-security': minor
---

Fix: the platform-admin promotion targets the oldest human that can SIGN IN, not the oldest `sys_user` row

Under the `single` posture the first-boot promotion ranked candidates by age
alone, and "human" was its only filter. On an app that declares people in
`defineStack({ data })` that picked the wrong row every time: a declared person
is a credential-less directory row, the declarative seed is awaited inside
`AppPlugin.start()` (kernel Phase 2), so those rows are always older than any
account created at `kernel:ready` or later.

Measured on a driven composed boot, not inferred: `admin_full_access` was
granted to `person0@demo.example` — a row with no `sys_account`, on a database
whose `sys_account` table was entirely empty — and `claimSeedOwnership` handed
that same unusable row both seeded business records. A real sign-up arriving
afterwards was never promoted, because the promotion had already short-circuited
on "an admin exists". The grant was written, unexercisable, and permanent.

The target is now the oldest human holding a `sys_account`. Any provider counts:
a federated or SSO account is a login, and narrowing to `credential` would
recreate this defect for SSO-only deployments. When human rows exist but none can
authenticate, nobody is promoted and no grant row is written — an `info` line
says so, and the bootstrap replay now also fires on `sys_account` inserts, so the
first real login is promoted the moment it exists. That second half is
load-bearing rather than incidental: a sign-up writes its `sys_user` row before
its `sys_account` row, so the pre-existing `sys_user` trigger fires while the
registrant still has no login.

Deployments that already carry a platform-admin grant are untouched. The
"an admin already exists" short-circuit runs before any target selection, so this
changes which row a FRESH bootstrap promotes and nothing else — moving an
already-granted platform admin is not this change's to make.
Original file line numberDiff line numberDiff line change
Expand Up@@ -114,6 +114,25 @@ function makeQl(userRows: unknown[]) {
sys_permission_set: [],
sys_user: userRows.map((r) => (r && typeof r === 'object' ? { ...(r as object) } : r)) as any[],
sys_user_permission_set: [],
// [#14348] Every probed row that CAN hold an account gets one.
//
// This probe reads plugin-security's human verdict indirectly, as
// `report.adminPromoted`, and since #14348 promotion is a conjunction:
// human AND holds a `sys_account` (a login). Leaving this table empty would
// make every row fail the second conjunct, so the probe would report
// "non-human" for rows both owners call human — a disagreement that is not
// there. Modelling the account keeps the HUMAN PREDICATE the only
// discriminator, which is what this file measures.
//
// Rows with no usable `id` get no account, because nothing could key one to
// them; that class is handled explicitly below rather than silently.
sys_account: userRows
.filter((r) => !!r && typeof r === 'object' && (r as any).id !== undefined && (r as any).id !== null)
.map((r) => ({
id: `acc_${String((r as any).id)}`,
user_id: (r as any).id,
provider_id: 'credential',
})),
};
return {
tables,
Expand DownExpand Up@@ -141,6 +160,21 @@ const ADMIN_SET = { name: 'admin_full_access', label: 'Administrator' } as any;
/**
* plugin-security's verdict on a single row, read through the published
* `bootstrapPlatformAdmin` entry point.
*
* ⚠️ [#14348] This is a PROXY, and it now carries more than the human
* predicate. `adminPromoted` means "human AND holds a `sys_account`", because
* the `single`-posture promotion moved off "the oldest human row" and onto "the
* oldest human that can authenticate" — a directory row seeded through
* `defineStack({ data })` is older than any account, so the old rule granted
* platform admin to a row nobody can sign in as.
*
* `makeQl` therefore models an account for every row that can key one, which
* holds the second conjunct constant and leaves the human predicate as the only
* discriminator this file measures. `isHumanUser` itself is UNCHANGED by
* #14348, and so is `isHumanUserRow`; nothing about the invariant moved.
*
* ⛔ Do not "simplify" this by dropping the account modelling: the tests would
* go red reporting a predicate disagreement that does not exist.
*/
async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?: string }> {
const ql = makeQl([row]);
Expand All@@ -152,7 +186,7 @@ async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?:
* The shared corpus. Every entry is a shape a `sys_user` read can really
* return, and each names the property it is here to hold.
*/
const CORPUS: { name: string; row: unknown }[] = [
const CORPUS: { name: string; row: unknown; idLessFailClosed?: true }[] = [
{
name: 'an ordinary human account',
row: { id: 'usr_alice', role: 'member', email: 'alice@example.test' },
Expand DownExpand Up@@ -190,8 +224,11 @@ const CORPUS: { name: string; row: unknown }[] = [
row: { id: `${SystemUserId.SYSTEM}_2`, role: 'member', email: 'frank@example.test' },
},
{
// [#14348] Human to BOTH predicates, and deliberately NOT probed through
// promotion — see the dedicated branch in the agreement loop below.
name: 'a row with neither id nor role',
row: { email: 'ghost@example.test' },
idLessFailClosed: true,
},
{ name: 'a null row', row: null },
{ name: 'an undefined row', row: undefined },
Expand DownExpand Up@@ -269,7 +306,65 @@ describe('human-user predicate agreement — plugin-security `isHumanUser` vs pl
}
});

for (const { name, row } of CORPUS) {
for (const { name, row, idLessFailClosed } of CORPUS) {
if (idLessFailClosed) {
/**
* [#14348] The one corpus row this probe cannot read a predicate verdict
* from — and why that is NOT a predicate disagreement.
*
* Both owners call `{ email: 'ghost@example.test' }` HUMAN, and they
* still agree: nothing in #14348 touched either predicate. What changed
* is the PROXY. Promotion is now "human AND can authenticate", and the
* second conjunct is unanswerable for a row with no `id`: there is no key
* to hang a `sys_account` on, so no account can exist and none can be
* modelled above. Reading `adminPromoted` here would therefore report the
* missing conjunct as a missing predicate agreement.
*
* So this row asserts the OUTCOME instead, and the outcome is
* fail-closed on purpose. A row with no `id` cannot hold an exercisable
* grant: the pre-#14348 code promoted it by writing
* `sys_user_permission_set.user_id = undefined` — a grant addressed to
* nobody, in the table whose whole job is to say who may administer the
* platform. Refusing it is the same direction this file's own
* NON_OBJECT_CORPUS already fixed ("for a promotion predicate the safe
* answer to malformed input is no"), applied to the one malformed shape
* that is a real object.
*
* ⛔ This is NOT licence to relax the agreement assertion for any other
* row. Every id-bearing row still proves the two predicates agree, and
* `no_authenticable_user` is asserted below precisely so this case cannot
* pass on a harness that failed earlier for some unrelated reason.
*/
it(`fails closed on ${name} — id-less, so no account can key to it (#14348)`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);

// The predicates still agree that this row is human: asserted on the
// owner side so a regression there cannot hide behind this case.
expect(
authSays,
'plugin-auth isHumanUserRow must still call an id-less human row HUMAN',
).toBe(true);

// ...and promotion still refuses it, for the second conjunct.
expect(
security.human,
`an id-less row must NOT be promoted: the grant row it would write is\n` +
`addressed to \`user_id: undefined\`, which no principal can ever exercise.\n` +
` row: ${JSON.stringify(row)}\n` +
` reason: ${security.reason ?? 'none'}`,
).toBe(false);

// Prove the refusal came from the authenticable filter and not from an
// earlier branch — the same anti-vacuity discipline the loop below uses.
expect(
security.reason,
'refusal did not come from the authenticable filter',
).toBe('no_authenticable_user');
});
continue;
}

it(`agrees on ${name}`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/promote-authenticable-first-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
---
'@objectstack/plugin-security': minor
---

Fix: the platform-admin promotion targets the oldest human that can SIGN IN, not the oldest `sys_user` row

Under the `single` posture the first-boot promotion ranked candidates by age
alone, and "human" was its only filter. On an app that declares people in
`defineStack({ data })` that picked the wrong row every time: a declared person
is a credential-less directory row, the declarative seed is awaited inside
`AppPlugin.start()` (kernel Phase 2), so those rows are always older than any
account created at `kernel:ready` or later.

Measured on a driven composed boot, not inferred: `admin_full_access` was
granted to `person0@demo.example` — a row with no `sys_account`, on a database
whose `sys_account` table was entirely empty — and `claimSeedOwnership` handed
that same unusable row both seeded business records. A real sign-up arriving
afterwards was never promoted, because the promotion had already short-circuited
on "an admin exists". The grant was written, unexercisable, and permanent.

The target is now the oldest human holding a `sys_account`. Any provider counts:
a federated or SSO account is a login, and narrowing to `credential` would
recreate this defect for SSO-only deployments. When human rows exist but none can
authenticate, nobody is promoted and no grant row is written — an `info` line
says so, and the bootstrap replay now also fires on `sys_account` inserts, so the
first real login is promoted the moment it exists. That second half is
load-bearing rather than incidental: a sign-up writes its `sys_user` row before
its `sys_account` row, so the pre-existing `sys_user` trigger fires while the
registrant still has no login.

Deployments that already carry a platform-admin grant are untouched. The
"an admin already exists" short-circuit runs before any target selection, so this
changes which row a FRESH bootstrap promotes and nothing else — moving an
already-granted platform admin is not this change's to make.
Original file line numberDiff line numberDiff line change
Expand Up@@ -114,6 +114,25 @@ function makeQl(userRows: unknown[]) {
sys_permission_set: [],
sys_user: userRows.map((r) => (r && typeof r === 'object' ? { ...(r as object) } : r)) as any[],
sys_user_permission_set: [],
// [#14348] Every probed row that CAN hold an account gets one.
//
// This probe reads plugin-security's human verdict indirectly, as
// `report.adminPromoted`, and since #14348 promotion is a conjunction:
// human AND holds a `sys_account` (a login). Leaving this table empty would
// make every row fail the second conjunct, so the probe would report
// "non-human" for rows both owners call human — a disagreement that is not
// there. Modelling the account keeps the HUMAN PREDICATE the only
// discriminator, which is what this file measures.
//
// Rows with no usable `id` get no account, because nothing could key one to
// them; that class is handled explicitly below rather than silently.
sys_account: userRows
.filter((r) => !!r && typeof r === 'object' && (r as any).id !== undefined && (r as any).id !== null)
.map((r) => ({
id: `acc_${String((r as any).id)}`,
user_id: (r as any).id,
provider_id: 'credential',
})),
};
return {
tables,
Expand DownExpand Up@@ -141,6 +160,21 @@ const ADMIN_SET = { name: 'admin_full_access', label: 'Administrator' } as any;
/**
* plugin-security's verdict on a single row, read through the published
* `bootstrapPlatformAdmin` entry point.
*
* ⚠️ [#14348] This is a PROXY, and it now carries more than the human
* predicate. `adminPromoted` means "human AND holds a `sys_account`", because
* the `single`-posture promotion moved off "the oldest human row" and onto "the
* oldest human that can authenticate" — a directory row seeded through
* `defineStack({ data })` is older than any account, so the old rule granted
* platform admin to a row nobody can sign in as.
*
* `makeQl` therefore models an account for every row that can key one, which
* holds the second conjunct constant and leaves the human predicate as the only
* discriminator this file measures. `isHumanUser` itself is UNCHANGED by
* #14348, and so is `isHumanUserRow`; nothing about the invariant moved.
*
* ⛔ Do not "simplify" this by dropping the account modelling: the tests would
* go red reporting a predicate disagreement that does not exist.
*/
async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?: string }> {
const ql = makeQl([row]);
Expand All@@ -152,7 +186,7 @@ async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?:
* The shared corpus. Every entry is a shape a `sys_user` read can really
* return, and each names the property it is here to hold.
*/
const CORPUS: { name: string; row: unknown }[] = [
const CORPUS: { name: string; row: unknown; idLessFailClosed?: true }[] = [
{
name: 'an ordinary human account',
row: { id: 'usr_alice', role: 'member', email: 'alice@example.test' },
Expand DownExpand Up@@ -190,8 +224,11 @@ const CORPUS: { name: string; row: unknown }[] = [
row: { id: `${SystemUserId.SYSTEM}_2`, role: 'member', email: 'frank@example.test' },
},
{
// [#14348] Human to BOTH predicates, and deliberately NOT probed through
// promotion — see the dedicated branch in the agreement loop below.
name: 'a row with neither id nor role',
row: { email: 'ghost@example.test' },
idLessFailClosed: true,
},
{ name: 'a null row', row: null },
{ name: 'an undefined row', row: undefined },
Expand DownExpand Up@@ -269,7 +306,65 @@ describe('human-user predicate agreement — plugin-security `isHumanUser` vs pl
}
});

for (const { name, row } of CORPUS) {
for (const { name, row, idLessFailClosed } of CORPUS) {
if (idLessFailClosed) {
/**
* [#14348] The one corpus row this probe cannot read a predicate verdict
* from — and why that is NOT a predicate disagreement.
*
* Both owners call `{ email: 'ghost@example.test' }` HUMAN, and they
* still agree: nothing in #14348 touched either predicate. What changed
* is the PROXY. Promotion is now "human AND can authenticate", and the
* second conjunct is unanswerable for a row with no `id`: there is no key
* to hang a `sys_account` on, so no account can exist and none can be
* modelled above. Reading `adminPromoted` here would therefore report the
* missing conjunct as a missing predicate agreement.
*
* So this row asserts the OUTCOME instead, and the outcome is
* fail-closed on purpose. A row with no `id` cannot hold an exercisable
* grant: the pre-#14348 code promoted it by writing
* `sys_user_permission_set.user_id = undefined` — a grant addressed to
* nobody, in the table whose whole job is to say who may administer the
* platform. Refusing it is the same direction this file's own
* NON_OBJECT_CORPUS already fixed ("for a promotion predicate the safe
* answer to malformed input is no"), applied to the one malformed shape
* that is a real object.
*
* ⛔ This is NOT licence to relax the agreement assertion for any other
* row. Every id-bearing row still proves the two predicates agree, and
* `no_authenticable_user` is asserted below precisely so this case cannot
* pass on a harness that failed earlier for some unrelated reason.
*/
it(`fails closed on ${name} — id-less, so no account can key to it (#14348)`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);

// The predicates still agree that this row is human: asserted on the
// owner side so a regression there cannot hide behind this case.
expect(
authSays,
'plugin-auth isHumanUserRow must still call an id-less human row HUMAN',
).toBe(true);

// ...and promotion still refuses it, for the second conjunct.
expect(
security.human,
`an id-less row must NOT be promoted: the grant row it would write is\n` +
`addressed to \`user_id: undefined\`, which no principal can ever exercise.\n` +
` row: ${JSON.stringify(row)}\n` +
` reason: ${security.reason ?? 'none'}`,
).toBe(false);

// Prove the refusal came from the authenticable filter and not from an
// earlier branch — the same anti-vacuity discipline the loop below uses.
expect(
security.reason,
'refusal did not come from the authenticable filter',
).toBe('no_authenticable_user');
});
continue;
}

it(`agrees on ${name}`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/promote-authenticable-first-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
---
'@objectstack/plugin-security': minor
---

Fix: the platform-admin promotion targets the oldest human that can SIGN IN, not the oldest `sys_user` row

Under the `single` posture the first-boot promotion ranked candidates by age
alone, and "human" was its only filter. On an app that declares people in
`defineStack({ data })` that picked the wrong row every time: a declared person
is a credential-less directory row, the declarative seed is awaited inside
`AppPlugin.start()` (kernel Phase 2), so those rows are always older than any
account created at `kernel:ready` or later.

Measured on a driven composed boot, not inferred: `admin_full_access` was
granted to `person0@demo.example` — a row with no `sys_account`, on a database
whose `sys_account` table was entirely empty — and `claimSeedOwnership` handed
that same unusable row both seeded business records. A real sign-up arriving
afterwards was never promoted, because the promotion had already short-circuited
on "an admin exists". The grant was written, unexercisable, and permanent.

The target is now the oldest human holding a `sys_account`. Any provider counts:
a federated or SSO account is a login, and narrowing to `credential` would
recreate this defect for SSO-only deployments. When human rows exist but none can
authenticate, nobody is promoted and no grant row is written — an `info` line
says so, and the bootstrap replay now also fires on `sys_account` inserts, so the
first real login is promoted the moment it exists. That second half is
load-bearing rather than incidental: a sign-up writes its `sys_user` row before
its `sys_account` row, so the pre-existing `sys_user` trigger fires while the
registrant still has no login.

Deployments that already carry a platform-admin grant are untouched. The
"an admin already exists" short-circuit runs before any target selection, so this
changes which row a FRESH bootstrap promotes and nothing else — moving an
already-granted platform admin is not this change's to make.
Original file line numberDiff line numberDiff line change
Expand Up@@ -114,6 +114,25 @@ function makeQl(userRows: unknown[]) {
sys_permission_set: [],
sys_user: userRows.map((r) => (r && typeof r === 'object' ? { ...(r as object) } : r)) as any[],
sys_user_permission_set: [],
// [#14348] Every probed row that CAN hold an account gets one.
//
// This probe reads plugin-security's human verdict indirectly, as
// `report.adminPromoted`, and since #14348 promotion is a conjunction:
// human AND holds a `sys_account` (a login). Leaving this table empty would
// make every row fail the second conjunct, so the probe would report
// "non-human" for rows both owners call human — a disagreement that is not
// there. Modelling the account keeps the HUMAN PREDICATE the only
// discriminator, which is what this file measures.
//
// Rows with no usable `id` get no account, because nothing could key one to
// them; that class is handled explicitly below rather than silently.
sys_account: userRows
.filter((r) => !!r && typeof r === 'object' && (r as any).id !== undefined && (r as any).id !== null)
.map((r) => ({
id: `acc_${String((r as any).id)}`,
user_id: (r as any).id,
provider_id: 'credential',
})),
};
return {
tables,
Expand DownExpand Up@@ -141,6 +160,21 @@ const ADMIN_SET = { name: 'admin_full_access', label: 'Administrator' } as any;
/**
* plugin-security's verdict on a single row, read through the published
* `bootstrapPlatformAdmin` entry point.
*
* ⚠️ [#14348] This is a PROXY, and it now carries more than the human
* predicate. `adminPromoted` means "human AND holds a `sys_account`", because
* the `single`-posture promotion moved off "the oldest human row" and onto "the
* oldest human that can authenticate" — a directory row seeded through
* `defineStack({ data })` is older than any account, so the old rule granted
* platform admin to a row nobody can sign in as.
*
* `makeQl` therefore models an account for every row that can key one, which
* holds the second conjunct constant and leaves the human predicate as the only
* discriminator this file measures. `isHumanUser` itself is UNCHANGED by
* #14348, and so is `isHumanUserRow`; nothing about the invariant moved.
*
* ⛔ Do not "simplify" this by dropping the account modelling: the tests would
* go red reporting a predicate disagreement that does not exist.
*/
async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?: string }> {
const ql = makeQl([row]);
Expand All@@ -152,7 +186,7 @@ async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?:
* The shared corpus. Every entry is a shape a `sys_user` read can really
* return, and each names the property it is here to hold.
*/
const CORPUS: { name: string; row: unknown }[] = [
const CORPUS: { name: string; row: unknown; idLessFailClosed?: true }[] = [
{
name: 'an ordinary human account',
row: { id: 'usr_alice', role: 'member', email: 'alice@example.test' },
Expand DownExpand Up@@ -190,8 +224,11 @@ const CORPUS: { name: string; row: unknown }[] = [
row: { id: `${SystemUserId.SYSTEM}_2`, role: 'member', email: 'frank@example.test' },
},
{
// [#14348] Human to BOTH predicates, and deliberately NOT probed through
// promotion — see the dedicated branch in the agreement loop below.
name: 'a row with neither id nor role',
row: { email: 'ghost@example.test' },
idLessFailClosed: true,
},
{ name: 'a null row', row: null },
{ name: 'an undefined row', row: undefined },
Expand DownExpand Up@@ -269,7 +306,65 @@ describe('human-user predicate agreement — plugin-security `isHumanUser` vs pl
}
});

for (const { name, row } of CORPUS) {
for (const { name, row, idLessFailClosed } of CORPUS) {
if (idLessFailClosed) {
/**
* [#14348] The one corpus row this probe cannot read a predicate verdict
* from — and why that is NOT a predicate disagreement.
*
* Both owners call `{ email: 'ghost@example.test' }` HUMAN, and they
* still agree: nothing in #14348 touched either predicate. What changed
* is the PROXY. Promotion is now "human AND can authenticate", and the
* second conjunct is unanswerable for a row with no `id`: there is no key
* to hang a `sys_account` on, so no account can exist and none can be
* modelled above. Reading `adminPromoted` here would therefore report the
* missing conjunct as a missing predicate agreement.
*
* So this row asserts the OUTCOME instead, and the outcome is
* fail-closed on purpose. A row with no `id` cannot hold an exercisable
* grant: the pre-#14348 code promoted it by writing
* `sys_user_permission_set.user_id = undefined` — a grant addressed to
* nobody, in the table whose whole job is to say who may administer the
* platform. Refusing it is the same direction this file's own
* NON_OBJECT_CORPUS already fixed ("for a promotion predicate the safe
* answer to malformed input is no"), applied to the one malformed shape
* that is a real object.
*
* ⛔ This is NOT licence to relax the agreement assertion for any other
* row. Every id-bearing row still proves the two predicates agree, and
* `no_authenticable_user` is asserted below precisely so this case cannot
* pass on a harness that failed earlier for some unrelated reason.
*/
it(`fails closed on ${name} — id-less, so no account can key to it (#14348)`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);

// The predicates still agree that this row is human: asserted on the
// owner side so a regression there cannot hide behind this case.
expect(
authSays,
'plugin-auth isHumanUserRow must still call an id-less human row HUMAN',
).toBe(true);

// ...and promotion still refuses it, for the second conjunct.
expect(
security.human,
`an id-less row must NOT be promoted: the grant row it would write is\n` +
`addressed to \`user_id: undefined\`, which no principal can ever exercise.\n` +
` row: ${JSON.stringify(row)}\n` +
` reason: ${security.reason ?? 'none'}`,
).toBe(false);

// Prove the refusal came from the authenticable filter and not from an
// earlier branch — the same anti-vacuity discipline the loop below uses.
expect(
security.reason,
'refusal did not come from the authenticable filter',
).toBe('no_authenticable_user');
});
continue;
}

it(`agrees on ${name}`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/promote-authenticable-first-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
---
'@objectstack/plugin-security': minor
---

Fix: the platform-admin promotion targets the oldest human that can SIGN IN, not the oldest `sys_user` row

Under the `single` posture the first-boot promotion ranked candidates by age
alone, and "human" was its only filter. On an app that declares people in
`defineStack({ data })` that picked the wrong row every time: a declared person
is a credential-less directory row, the declarative seed is awaited inside
`AppPlugin.start()` (kernel Phase 2), so those rows are always older than any
account created at `kernel:ready` or later.

Measured on a driven composed boot, not inferred: `admin_full_access` was
granted to `person0@demo.example` — a row with no `sys_account`, on a database
whose `sys_account` table was entirely empty — and `claimSeedOwnership` handed
that same unusable row both seeded business records. A real sign-up arriving
afterwards was never promoted, because the promotion had already short-circuited
on "an admin exists". The grant was written, unexercisable, and permanent.

The target is now the oldest human holding a `sys_account`. Any provider counts:
a federated or SSO account is a login, and narrowing to `credential` would
recreate this defect for SSO-only deployments. When human rows exist but none can
authenticate, nobody is promoted and no grant row is written — an `info` line
says so, and the bootstrap replay now also fires on `sys_account` inserts, so the
first real login is promoted the moment it exists. That second half is
load-bearing rather than incidental: a sign-up writes its `sys_user` row before
its `sys_account` row, so the pre-existing `sys_user` trigger fires while the
registrant still has no login.

Deployments that already carry a platform-admin grant are untouched. The
"an admin already exists" short-circuit runs before any target selection, so this
changes which row a FRESH bootstrap promotes and nothing else — moving an
already-granted platform admin is not this change's to make.
Original file line numberDiff line numberDiff line change
Expand Up@@ -114,6 +114,25 @@ function makeQl(userRows: unknown[]) {
sys_permission_set: [],
sys_user: userRows.map((r) => (r && typeof r === 'object' ? { ...(r as object) } : r)) as any[],
sys_user_permission_set: [],
// [#14348] Every probed row that CAN hold an account gets one.
//
// This probe reads plugin-security's human verdict indirectly, as
// `report.adminPromoted`, and since #14348 promotion is a conjunction:
// human AND holds a `sys_account` (a login). Leaving this table empty would
// make every row fail the second conjunct, so the probe would report
// "non-human" for rows both owners call human — a disagreement that is not
// there. Modelling the account keeps the HUMAN PREDICATE the only
// discriminator, which is what this file measures.
//
// Rows with no usable `id` get no account, because nothing could key one to
// them; that class is handled explicitly below rather than silently.
sys_account: userRows
.filter((r) => !!r && typeof r === 'object' && (r as any).id !== undefined && (r as any).id !== null)
.map((r) => ({
id: `acc_${String((r as any).id)}`,
user_id: (r as any).id,
provider_id: 'credential',
})),
};
return {
tables,
Expand DownExpand Up@@ -141,6 +160,21 @@ const ADMIN_SET = { name: 'admin_full_access', label: 'Administrator' } as any;
/**
* plugin-security's verdict on a single row, read through the published
* `bootstrapPlatformAdmin` entry point.
*
* ⚠️ [#14348] This is a PROXY, and it now carries more than the human
* predicate. `adminPromoted` means "human AND holds a `sys_account`", because
* the `single`-posture promotion moved off "the oldest human row" and onto "the
* oldest human that can authenticate" — a directory row seeded through
* `defineStack({ data })` is older than any account, so the old rule granted
* platform admin to a row nobody can sign in as.
*
* `makeQl` therefore models an account for every row that can key one, which
* holds the second conjunct constant and leaves the human predicate as the only
* discriminator this file measures. `isHumanUser` itself is UNCHANGED by
* #14348, and so is `isHumanUserRow`; nothing about the invariant moved.
*
* ⛔ Do not "simplify" this by dropping the account modelling: the tests would
* go red reporting a predicate disagreement that does not exist.
*/
async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?: string }> {
const ql = makeQl([row]);
Expand All@@ -152,7 +186,7 @@ async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?:
* The shared corpus. Every entry is a shape a `sys_user` read can really
* return, and each names the property it is here to hold.
*/
const CORPUS: { name: string; row: unknown }[] = [
const CORPUS: { name: string; row: unknown; idLessFailClosed?: true }[] = [
{
name: 'an ordinary human account',
row: { id: 'usr_alice', role: 'member', email: 'alice@example.test' },
Expand DownExpand Up@@ -190,8 +224,11 @@ const CORPUS: { name: string; row: unknown }[] = [
row: { id: `${SystemUserId.SYSTEM}_2`, role: 'member', email: 'frank@example.test' },
},
{
// [#14348] Human to BOTH predicates, and deliberately NOT probed through
// promotion — see the dedicated branch in the agreement loop below.
name: 'a row with neither id nor role',
row: { email: 'ghost@example.test' },
idLessFailClosed: true,
},
{ name: 'a null row', row: null },
{ name: 'an undefined row', row: undefined },
Expand DownExpand Up@@ -269,7 +306,65 @@ describe('human-user predicate agreement — plugin-security `isHumanUser` vs pl
}
});

for (const { name, row } of CORPUS) {
for (const { name, row, idLessFailClosed } of CORPUS) {
if (idLessFailClosed) {
/**
* [#14348] The one corpus row this probe cannot read a predicate verdict
* from — and why that is NOT a predicate disagreement.
*
* Both owners call `{ email: 'ghost@example.test' }` HUMAN, and they
* still agree: nothing in #14348 touched either predicate. What changed
* is the PROXY. Promotion is now "human AND can authenticate", and the
* second conjunct is unanswerable for a row with no `id`: there is no key
* to hang a `sys_account` on, so no account can exist and none can be
* modelled above. Reading `adminPromoted` here would therefore report the
* missing conjunct as a missing predicate agreement.
*
* So this row asserts the OUTCOME instead, and the outcome is
* fail-closed on purpose. A row with no `id` cannot hold an exercisable
* grant: the pre-#14348 code promoted it by writing
* `sys_user_permission_set.user_id = undefined` — a grant addressed to
* nobody, in the table whose whole job is to say who may administer the
* platform. Refusing it is the same direction this file's own
* NON_OBJECT_CORPUS already fixed ("for a promotion predicate the safe
* answer to malformed input is no"), applied to the one malformed shape
* that is a real object.
*
* ⛔ This is NOT licence to relax the agreement assertion for any other
* row. Every id-bearing row still proves the two predicates agree, and
* `no_authenticable_user` is asserted below precisely so this case cannot
* pass on a harness that failed earlier for some unrelated reason.
*/
it(`fails closed on ${name} — id-less, so no account can key to it (#14348)`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);

// The predicates still agree that this row is human: asserted on the
// owner side so a regression there cannot hide behind this case.
expect(
authSays,
'plugin-auth isHumanUserRow must still call an id-less human row HUMAN',
).toBe(true);

// ...and promotion still refuses it, for the second conjunct.
expect(
security.human,
`an id-less row must NOT be promoted: the grant row it would write is\n` +
`addressed to \`user_id: undefined\`, which no principal can ever exercise.\n` +
` row: ${JSON.stringify(row)}\n` +
` reason: ${security.reason ?? 'none'}`,
).toBe(false);

// Prove the refusal came from the authenticable filter and not from an
// earlier branch — the same anti-vacuity discipline the loop below uses.
expect(
security.reason,
'refusal did not come from the authenticable filter',
).toBe('no_authenticable_user');
});
continue;
}

it(`agrees on ${name}`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/promote-authenticable-first-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
---
'@objectstack/plugin-security': minor
---

Fix: the platform-admin promotion targets the oldest human that can SIGN IN, not the oldest `sys_user` row

Under the `single` posture the first-boot promotion ranked candidates by age
alone, and "human" was its only filter. On an app that declares people in
`defineStack({ data })` that picked the wrong row every time: a declared person
is a credential-less directory row, the declarative seed is awaited inside
`AppPlugin.start()` (kernel Phase 2), so those rows are always older than any
account created at `kernel:ready` or later.

Measured on a driven composed boot, not inferred: `admin_full_access` was
granted to `person0@demo.example` — a row with no `sys_account`, on a database
whose `sys_account` table was entirely empty — and `claimSeedOwnership` handed
that same unusable row both seeded business records. A real sign-up arriving
afterwards was never promoted, because the promotion had already short-circuited
on "an admin exists". The grant was written, unexercisable, and permanent.

The target is now the oldest human holding a `sys_account`. Any provider counts:
a federated or SSO account is a login, and narrowing to `credential` would
recreate this defect for SSO-only deployments. When human rows exist but none can
authenticate, nobody is promoted and no grant row is written — an `info` line
says so, and the bootstrap replay now also fires on `sys_account` inserts, so the
first real login is promoted the moment it exists. That second half is
load-bearing rather than incidental: a sign-up writes its `sys_user` row before
its `sys_account` row, so the pre-existing `sys_user` trigger fires while the
registrant still has no login.

Deployments that already carry a platform-admin grant are untouched. The
"an admin already exists" short-circuit runs before any target selection, so this
changes which row a FRESH bootstrap promotes and nothing else — moving an
already-granted platform admin is not this change's to make.
Original file line numberDiff line numberDiff line change
Expand Up@@ -114,6 +114,25 @@ function makeQl(userRows: unknown[]) {
sys_permission_set: [],
sys_user: userRows.map((r) => (r && typeof r === 'object' ? { ...(r as object) } : r)) as any[],
sys_user_permission_set: [],
// [#14348] Every probed row that CAN hold an account gets one.
//
// This probe reads plugin-security's human verdict indirectly, as
// `report.adminPromoted`, and since #14348 promotion is a conjunction:
// human AND holds a `sys_account` (a login). Leaving this table empty would
// make every row fail the second conjunct, so the probe would report
// "non-human" for rows both owners call human — a disagreement that is not
// there. Modelling the account keeps the HUMAN PREDICATE the only
// discriminator, which is what this file measures.
//
// Rows with no usable `id` get no account, because nothing could key one to
// them; that class is handled explicitly below rather than silently.
sys_account: userRows
.filter((r) => !!r && typeof r === 'object' && (r as any).id !== undefined && (r as any).id !== null)
.map((r) => ({
id: `acc_${String((r as any).id)}`,
user_id: (r as any).id,
provider_id: 'credential',
})),
};
return {
tables,
Expand DownExpand Up@@ -141,6 +160,21 @@ const ADMIN_SET = { name: 'admin_full_access', label: 'Administrator' } as any;
/**
* plugin-security's verdict on a single row, read through the published
* `bootstrapPlatformAdmin` entry point.
*
* ⚠️ [#14348] This is a PROXY, and it now carries more than the human
* predicate. `adminPromoted` means "human AND holds a `sys_account`", because
* the `single`-posture promotion moved off "the oldest human row" and onto "the
* oldest human that can authenticate" — a directory row seeded through
* `defineStack({ data })` is older than any account, so the old rule granted
* platform admin to a row nobody can sign in as.
*
* `makeQl` therefore models an account for every row that can key one, which
* holds the second conjunct constant and leaves the human predicate as the only
* discriminator this file measures. `isHumanUser` itself is UNCHANGED by
* #14348, and so is `isHumanUserRow`; nothing about the invariant moved.
*
* ⛔ Do not "simplify" this by dropping the account modelling: the tests would
* go red reporting a predicate disagreement that does not exist.
*/
async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?: string }> {
const ql = makeQl([row]);
Expand All@@ -152,7 +186,7 @@ async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?:
* The shared corpus. Every entry is a shape a `sys_user` read can really
* return, and each names the property it is here to hold.
*/
const CORPUS: { name: string; row: unknown }[] = [
const CORPUS: { name: string; row: unknown; idLessFailClosed?: true }[] = [
{
name: 'an ordinary human account',
row: { id: 'usr_alice', role: 'member', email: 'alice@example.test' },
Expand DownExpand Up@@ -190,8 +224,11 @@ const CORPUS: { name: string; row: unknown }[] = [
row: { id: `${SystemUserId.SYSTEM}_2`, role: 'member', email: 'frank@example.test' },
},
{
// [#14348] Human to BOTH predicates, and deliberately NOT probed through
// promotion — see the dedicated branch in the agreement loop below.
name: 'a row with neither id nor role',
row: { email: 'ghost@example.test' },
idLessFailClosed: true,
},
{ name: 'a null row', row: null },
{ name: 'an undefined row', row: undefined },
Expand DownExpand Up@@ -269,7 +306,65 @@ describe('human-user predicate agreement — plugin-security `isHumanUser` vs pl
}
});

for (const { name, row } of CORPUS) {
for (const { name, row, idLessFailClosed } of CORPUS) {
if (idLessFailClosed) {
/**
* [#14348] The one corpus row this probe cannot read a predicate verdict
* from — and why that is NOT a predicate disagreement.
*
* Both owners call `{ email: 'ghost@example.test' }` HUMAN, and they
* still agree: nothing in #14348 touched either predicate. What changed
* is the PROXY. Promotion is now "human AND can authenticate", and the
* second conjunct is unanswerable for a row with no `id`: there is no key
* to hang a `sys_account` on, so no account can exist and none can be
* modelled above. Reading `adminPromoted` here would therefore report the
* missing conjunct as a missing predicate agreement.
*
* So this row asserts the OUTCOME instead, and the outcome is
* fail-closed on purpose. A row with no `id` cannot hold an exercisable
* grant: the pre-#14348 code promoted it by writing
* `sys_user_permission_set.user_id = undefined` — a grant addressed to
* nobody, in the table whose whole job is to say who may administer the
* platform. Refusing it is the same direction this file's own
* NON_OBJECT_CORPUS already fixed ("for a promotion predicate the safe
* answer to malformed input is no"), applied to the one malformed shape
* that is a real object.
*
* ⛔ This is NOT licence to relax the agreement assertion for any other
* row. Every id-bearing row still proves the two predicates agree, and
* `no_authenticable_user` is asserted below precisely so this case cannot
* pass on a harness that failed earlier for some unrelated reason.
*/
it(`fails closed on ${name} — id-less, so no account can key to it (#14348)`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);

// The predicates still agree that this row is human: asserted on the
// owner side so a regression there cannot hide behind this case.
expect(
authSays,
'plugin-auth isHumanUserRow must still call an id-less human row HUMAN',
).toBe(true);

// ...and promotion still refuses it, for the second conjunct.
expect(
security.human,
`an id-less row must NOT be promoted: the grant row it would write is\n` +
`addressed to \`user_id: undefined\`, which no principal can ever exercise.\n` +
` row: ${JSON.stringify(row)}\n` +
` reason: ${security.reason ?? 'none'}`,
).toBe(false);

// Prove the refusal came from the authenticable filter and not from an
// earlier branch — the same anti-vacuity discipline the loop below uses.
expect(
security.reason,
'refusal did not come from the authenticable filter',
).toBe('no_authenticable_user');
});
continue;
}

it(`agrees on ${name}`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/promote-authenticable-first-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
---
'@objectstack/plugin-security': minor
---

Fix: the platform-admin promotion targets the oldest human that can SIGN IN, not the oldest `sys_user` row

Under the `single` posture the first-boot promotion ranked candidates by age
alone, and "human" was its only filter. On an app that declares people in
`defineStack({ data })` that picked the wrong row every time: a declared person
is a credential-less directory row, the declarative seed is awaited inside
`AppPlugin.start()` (kernel Phase 2), so those rows are always older than any
account created at `kernel:ready` or later.

Measured on a driven composed boot, not inferred: `admin_full_access` was
granted to `person0@demo.example` — a row with no `sys_account`, on a database
whose `sys_account` table was entirely empty — and `claimSeedOwnership` handed
that same unusable row both seeded business records. A real sign-up arriving
afterwards was never promoted, because the promotion had already short-circuited
on "an admin exists". The grant was written, unexercisable, and permanent.

The target is now the oldest human holding a `sys_account`. Any provider counts:
a federated or SSO account is a login, and narrowing to `credential` would
recreate this defect for SSO-only deployments. When human rows exist but none can
authenticate, nobody is promoted and no grant row is written — an `info` line
says so, and the bootstrap replay now also fires on `sys_account` inserts, so the
first real login is promoted the moment it exists. That second half is
load-bearing rather than incidental: a sign-up writes its `sys_user` row before
its `sys_account` row, so the pre-existing `sys_user` trigger fires while the
registrant still has no login.

Deployments that already carry a platform-admin grant are untouched. The
"an admin already exists" short-circuit runs before any target selection, so this
changes which row a FRESH bootstrap promotes and nothing else — moving an
already-granted platform admin is not this change's to make.
Original file line numberDiff line numberDiff line change
Expand Up@@ -114,6 +114,25 @@ function makeQl(userRows: unknown[]) {
sys_permission_set: [],
sys_user: userRows.map((r) => (r && typeof r === 'object' ? { ...(r as object) } : r)) as any[],
sys_user_permission_set: [],
// [#14348] Every probed row that CAN hold an account gets one.
//
// This probe reads plugin-security's human verdict indirectly, as
// `report.adminPromoted`, and since #14348 promotion is a conjunction:
// human AND holds a `sys_account` (a login). Leaving this table empty would
// make every row fail the second conjunct, so the probe would report
// "non-human" for rows both owners call human — a disagreement that is not
// there. Modelling the account keeps the HUMAN PREDICATE the only
// discriminator, which is what this file measures.
//
// Rows with no usable `id` get no account, because nothing could key one to
// them; that class is handled explicitly below rather than silently.
sys_account: userRows
.filter((r) => !!r && typeof r === 'object' && (r as any).id !== undefined && (r as any).id !== null)
.map((r) => ({
id: `acc_${String((r as any).id)}`,
user_id: (r as any).id,
provider_id: 'credential',
})),
};
return {
tables,
Expand DownExpand Up@@ -141,6 +160,21 @@ const ADMIN_SET = { name: 'admin_full_access', label: 'Administrator' } as any;
/**
* plugin-security's verdict on a single row, read through the published
* `bootstrapPlatformAdmin` entry point.
*
* ⚠️ [#14348] This is a PROXY, and it now carries more than the human
* predicate. `adminPromoted` means "human AND holds a `sys_account`", because
* the `single`-posture promotion moved off "the oldest human row" and onto "the
* oldest human that can authenticate" — a directory row seeded through
* `defineStack({ data })` is older than any account, so the old rule granted
* platform admin to a row nobody can sign in as.
*
* `makeQl` therefore models an account for every row that can key one, which
* holds the second conjunct constant and leaves the human predicate as the only
* discriminator this file measures. `isHumanUser` itself is UNCHANGED by
* #14348, and so is `isHumanUserRow`; nothing about the invariant moved.
*
* ⛔ Do not "simplify" this by dropping the account modelling: the tests would
* go red reporting a predicate disagreement that does not exist.
*/
async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?: string }> {
const ql = makeQl([row]);
Expand All@@ -152,7 +186,7 @@ async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?:
* The shared corpus. Every entry is a shape a `sys_user` read can really
* return, and each names the property it is here to hold.
*/
const CORPUS: { name: string; row: unknown }[] = [
const CORPUS: { name: string; row: unknown; idLessFailClosed?: true }[] = [
{
name: 'an ordinary human account',
row: { id: 'usr_alice', role: 'member', email: 'alice@example.test' },
Expand DownExpand Up@@ -190,8 +224,11 @@ const CORPUS: { name: string; row: unknown }[] = [
row: { id: `${SystemUserId.SYSTEM}_2`, role: 'member', email: 'frank@example.test' },
},
{
// [#14348] Human to BOTH predicates, and deliberately NOT probed through
// promotion — see the dedicated branch in the agreement loop below.
name: 'a row with neither id nor role',
row: { email: 'ghost@example.test' },
idLessFailClosed: true,
},
{ name: 'a null row', row: null },
{ name: 'an undefined row', row: undefined },
Expand DownExpand Up@@ -269,7 +306,65 @@ describe('human-user predicate agreement — plugin-security `isHumanUser` vs pl
}
});

for (const { name, row } of CORPUS) {
for (const { name, row, idLessFailClosed } of CORPUS) {
if (idLessFailClosed) {
/**
* [#14348] The one corpus row this probe cannot read a predicate verdict
* from — and why that is NOT a predicate disagreement.
*
* Both owners call `{ email: 'ghost@example.test' }` HUMAN, and they
* still agree: nothing in #14348 touched either predicate. What changed
* is the PROXY. Promotion is now "human AND can authenticate", and the
* second conjunct is unanswerable for a row with no `id`: there is no key
* to hang a `sys_account` on, so no account can exist and none can be
* modelled above. Reading `adminPromoted` here would therefore report the
* missing conjunct as a missing predicate agreement.
*
* So this row asserts the OUTCOME instead, and the outcome is
* fail-closed on purpose. A row with no `id` cannot hold an exercisable
* grant: the pre-#14348 code promoted it by writing
* `sys_user_permission_set.user_id = undefined` — a grant addressed to
* nobody, in the table whose whole job is to say who may administer the
* platform. Refusing it is the same direction this file's own
* NON_OBJECT_CORPUS already fixed ("for a promotion predicate the safe
* answer to malformed input is no"), applied to the one malformed shape
* that is a real object.
*
* ⛔ This is NOT licence to relax the agreement assertion for any other
* row. Every id-bearing row still proves the two predicates agree, and
* `no_authenticable_user` is asserted below precisely so this case cannot
* pass on a harness that failed earlier for some unrelated reason.
*/
it(`fails closed on ${name} — id-less, so no account can key to it (#14348)`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);

// The predicates still agree that this row is human: asserted on the
// owner side so a regression there cannot hide behind this case.
expect(
authSays,
'plugin-auth isHumanUserRow must still call an id-less human row HUMAN',
).toBe(true);

// ...and promotion still refuses it, for the second conjunct.
expect(
security.human,
`an id-less row must NOT be promoted: the grant row it would write is\n` +
`addressed to \`user_id: undefined\`, which no principal can ever exercise.\n` +
` row: ${JSON.stringify(row)}\n` +
` reason: ${security.reason ?? 'none'}`,
).toBe(false);

// Prove the refusal came from the authenticable filter and not from an
// earlier branch — the same anti-vacuity discipline the loop below uses.
expect(
security.reason,
'refusal did not come from the authenticable filter',
).toBe('no_authenticable_user');
});
continue;
}

it(`agrees on ${name}`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/promote-authenticable-first-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
---
'@objectstack/plugin-security': minor
---

Fix: the platform-admin promotion targets the oldest human that can SIGN IN, not the oldest `sys_user` row

Under the `single` posture the first-boot promotion ranked candidates by age
alone, and "human" was its only filter. On an app that declares people in
`defineStack({ data })` that picked the wrong row every time: a declared person
is a credential-less directory row, the declarative seed is awaited inside
`AppPlugin.start()` (kernel Phase 2), so those rows are always older than any
account created at `kernel:ready` or later.

Measured on a driven composed boot, not inferred: `admin_full_access` was
granted to `person0@demo.example` — a row with no `sys_account`, on a database
whose `sys_account` table was entirely empty — and `claimSeedOwnership` handed
that same unusable row both seeded business records. A real sign-up arriving
afterwards was never promoted, because the promotion had already short-circuited
on "an admin exists". The grant was written, unexercisable, and permanent.

The target is now the oldest human holding a `sys_account`. Any provider counts:
a federated or SSO account is a login, and narrowing to `credential` would
recreate this defect for SSO-only deployments. When human rows exist but none can
authenticate, nobody is promoted and no grant row is written — an `info` line
says so, and the bootstrap replay now also fires on `sys_account` inserts, so the
first real login is promoted the moment it exists. That second half is
load-bearing rather than incidental: a sign-up writes its `sys_user` row before
its `sys_account` row, so the pre-existing `sys_user` trigger fires while the
registrant still has no login.

Deployments that already carry a platform-admin grant are untouched. The
"an admin already exists" short-circuit runs before any target selection, so this
changes which row a FRESH bootstrap promotes and nothing else — moving an
already-granted platform admin is not this change's to make.
Original file line numberDiff line numberDiff line change
Expand Up@@ -114,6 +114,25 @@ function makeQl(userRows: unknown[]) {
sys_permission_set: [],
sys_user: userRows.map((r) => (r && typeof r === 'object' ? { ...(r as object) } : r)) as any[],
sys_user_permission_set: [],
// [#14348] Every probed row that CAN hold an account gets one.
//
// This probe reads plugin-security's human verdict indirectly, as
// `report.adminPromoted`, and since #14348 promotion is a conjunction:
// human AND holds a `sys_account` (a login). Leaving this table empty would
// make every row fail the second conjunct, so the probe would report
// "non-human" for rows both owners call human — a disagreement that is not
// there. Modelling the account keeps the HUMAN PREDICATE the only
// discriminator, which is what this file measures.
//
// Rows with no usable `id` get no account, because nothing could key one to
// them; that class is handled explicitly below rather than silently.
sys_account: userRows
.filter((r) => !!r && typeof r === 'object' && (r as any).id !== undefined && (r as any).id !== null)
.map((r) => ({
id: `acc_${String((r as any).id)}`,
user_id: (r as any).id,
provider_id: 'credential',
})),
};
return {
tables,
Expand DownExpand Up@@ -141,6 +160,21 @@ const ADMIN_SET = { name: 'admin_full_access', label: 'Administrator' } as any;
/**
* plugin-security's verdict on a single row, read through the published
* `bootstrapPlatformAdmin` entry point.
*
* ⚠️ [#14348] This is a PROXY, and it now carries more than the human
* predicate. `adminPromoted` means "human AND holds a `sys_account`", because
* the `single`-posture promotion moved off "the oldest human row" and onto "the
* oldest human that can authenticate" — a directory row seeded through
* `defineStack({ data })` is older than any account, so the old rule granted
* platform admin to a row nobody can sign in as.
*
* `makeQl` therefore models an account for every row that can key one, which
* holds the second conjunct constant and leaves the human predicate as the only
* discriminator this file measures. `isHumanUser` itself is UNCHANGED by
* #14348, and so is `isHumanUserRow`; nothing about the invariant moved.
*
* ⛔ Do not "simplify" this by dropping the account modelling: the tests would
* go red reporting a predicate disagreement that does not exist.
*/
async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?: string }> {
const ql = makeQl([row]);
Expand All@@ -152,7 +186,7 @@ async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?:
* The shared corpus. Every entry is a shape a `sys_user` read can really
* return, and each names the property it is here to hold.
*/
const CORPUS: { name: string; row: unknown }[] = [
const CORPUS: { name: string; row: unknown; idLessFailClosed?: true }[] = [
{
name: 'an ordinary human account',
row: { id: 'usr_alice', role: 'member', email: 'alice@example.test' },
Expand DownExpand Up@@ -190,8 +224,11 @@ const CORPUS: { name: string; row: unknown }[] = [
row: { id: `${SystemUserId.SYSTEM}_2`, role: 'member', email: 'frank@example.test' },
},
{
// [#14348] Human to BOTH predicates, and deliberately NOT probed through
// promotion — see the dedicated branch in the agreement loop below.
name: 'a row with neither id nor role',
row: { email: 'ghost@example.test' },
idLessFailClosed: true,
},
{ name: 'a null row', row: null },
{ name: 'an undefined row', row: undefined },
Expand DownExpand Up@@ -269,7 +306,65 @@ describe('human-user predicate agreement — plugin-security `isHumanUser` vs pl
}
});

for (const { name, row } of CORPUS) {
for (const { name, row, idLessFailClosed } of CORPUS) {
if (idLessFailClosed) {
/**
* [#14348] The one corpus row this probe cannot read a predicate verdict
* from — and why that is NOT a predicate disagreement.
*
* Both owners call `{ email: 'ghost@example.test' }` HUMAN, and they
* still agree: nothing in #14348 touched either predicate. What changed
* is the PROXY. Promotion is now "human AND can authenticate", and the
* second conjunct is unanswerable for a row with no `id`: there is no key
* to hang a `sys_account` on, so no account can exist and none can be
* modelled above. Reading `adminPromoted` here would therefore report the
* missing conjunct as a missing predicate agreement.
*
* So this row asserts the OUTCOME instead, and the outcome is
* fail-closed on purpose. A row with no `id` cannot hold an exercisable
* grant: the pre-#14348 code promoted it by writing
* `sys_user_permission_set.user_id = undefined` — a grant addressed to
* nobody, in the table whose whole job is to say who may administer the
* platform. Refusing it is the same direction this file's own
* NON_OBJECT_CORPUS already fixed ("for a promotion predicate the safe
* answer to malformed input is no"), applied to the one malformed shape
* that is a real object.
*
* ⛔ This is NOT licence to relax the agreement assertion for any other
* row. Every id-bearing row still proves the two predicates agree, and
* `no_authenticable_user` is asserted below precisely so this case cannot
* pass on a harness that failed earlier for some unrelated reason.
*/
it(`fails closed on ${name} — id-less, so no account can key to it (#14348)`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);

// The predicates still agree that this row is human: asserted on the
// owner side so a regression there cannot hide behind this case.
expect(
authSays,
'plugin-auth isHumanUserRow must still call an id-less human row HUMAN',
).toBe(true);

// ...and promotion still refuses it, for the second conjunct.
expect(
security.human,
`an id-less row must NOT be promoted: the grant row it would write is\n` +
`addressed to \`user_id: undefined\`, which no principal can ever exercise.\n` +
` row: ${JSON.stringify(row)}\n` +
` reason: ${security.reason ?? 'none'}`,
).toBe(false);

// Prove the refusal came from the authenticable filter and not from an
// earlier branch — the same anti-vacuity discipline the loop below uses.
expect(
security.reason,
'refusal did not come from the authenticable filter',
).toBe('no_authenticable_user');
});
continue;
}

it(`agrees on ${name}`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/promote-authenticable-first-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
---
'@objectstack/plugin-security': minor
---

Fix: the platform-admin promotion targets the oldest human that can SIGN IN, not the oldest `sys_user` row

Under the `single` posture the first-boot promotion ranked candidates by age
alone, and "human" was its only filter. On an app that declares people in
`defineStack({ data })` that picked the wrong row every time: a declared person
is a credential-less directory row, the declarative seed is awaited inside
`AppPlugin.start()` (kernel Phase 2), so those rows are always older than any
account created at `kernel:ready` or later.

Measured on a driven composed boot, not inferred: `admin_full_access` was
granted to `person0@demo.example` — a row with no `sys_account`, on a database
whose `sys_account` table was entirely empty — and `claimSeedOwnership` handed
that same unusable row both seeded business records. A real sign-up arriving
afterwards was never promoted, because the promotion had already short-circuited
on "an admin exists". The grant was written, unexercisable, and permanent.

The target is now the oldest human holding a `sys_account`. Any provider counts:
a federated or SSO account is a login, and narrowing to `credential` would
recreate this defect for SSO-only deployments. When human rows exist but none can
authenticate, nobody is promoted and no grant row is written — an `info` line
says so, and the bootstrap replay now also fires on `sys_account` inserts, so the
first real login is promoted the moment it exists. That second half is
load-bearing rather than incidental: a sign-up writes its `sys_user` row before
its `sys_account` row, so the pre-existing `sys_user` trigger fires while the
registrant still has no login.

Deployments that already carry a platform-admin grant are untouched. The
"an admin already exists" short-circuit runs before any target selection, so this
changes which row a FRESH bootstrap promotes and nothing else — moving an
already-granted platform admin is not this change's to make.
Original file line numberDiff line numberDiff line change
Expand Up@@ -114,6 +114,25 @@ function makeQl(userRows: unknown[]) {
sys_permission_set: [],
sys_user: userRows.map((r) => (r && typeof r === 'object' ? { ...(r as object) } : r)) as any[],
sys_user_permission_set: [],
// [#14348] Every probed row that CAN hold an account gets one.
//
// This probe reads plugin-security's human verdict indirectly, as
// `report.adminPromoted`, and since #14348 promotion is a conjunction:
// human AND holds a `sys_account` (a login). Leaving this table empty would
// make every row fail the second conjunct, so the probe would report
// "non-human" for rows both owners call human — a disagreement that is not
// there. Modelling the account keeps the HUMAN PREDICATE the only
// discriminator, which is what this file measures.
//
// Rows with no usable `id` get no account, because nothing could key one to
// them; that class is handled explicitly below rather than silently.
sys_account: userRows
.filter((r) => !!r && typeof r === 'object' && (r as any).id !== undefined && (r as any).id !== null)
.map((r) => ({
id: `acc_${String((r as any).id)}`,
user_id: (r as any).id,
provider_id: 'credential',
})),
};
return {
tables,
Expand DownExpand Up@@ -141,6 +160,21 @@ const ADMIN_SET = { name: 'admin_full_access', label: 'Administrator' } as any;
/**
* plugin-security's verdict on a single row, read through the published
* `bootstrapPlatformAdmin` entry point.
*
* ⚠️ [#14348] This is a PROXY, and it now carries more than the human
* predicate. `adminPromoted` means "human AND holds a `sys_account`", because
* the `single`-posture promotion moved off "the oldest human row" and onto "the
* oldest human that can authenticate" — a directory row seeded through
* `defineStack({ data })` is older than any account, so the old rule granted
* platform admin to a row nobody can sign in as.
*
* `makeQl` therefore models an account for every row that can key one, which
* holds the second conjunct constant and leaves the human predicate as the only
* discriminator this file measures. `isHumanUser` itself is UNCHANGED by
* #14348, and so is `isHumanUserRow`; nothing about the invariant moved.
*
* ⛔ Do not "simplify" this by dropping the account modelling: the tests would
* go red reporting a predicate disagreement that does not exist.
*/
async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?: string }> {
const ql = makeQl([row]);
Expand All@@ -152,7 +186,7 @@ async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?:
* The shared corpus. Every entry is a shape a `sys_user` read can really
* return, and each names the property it is here to hold.
*/
const CORPUS: { name: string; row: unknown }[] = [
const CORPUS: { name: string; row: unknown; idLessFailClosed?: true }[] = [
{
name: 'an ordinary human account',
row: { id: 'usr_alice', role: 'member', email: 'alice@example.test' },
Expand DownExpand Up@@ -190,8 +224,11 @@ const CORPUS: { name: string; row: unknown }[] = [
row: { id: `${SystemUserId.SYSTEM}_2`, role: 'member', email: 'frank@example.test' },
},
{
// [#14348] Human to BOTH predicates, and deliberately NOT probed through
// promotion — see the dedicated branch in the agreement loop below.
name: 'a row with neither id nor role',
row: { email: 'ghost@example.test' },
idLessFailClosed: true,
},
{ name: 'a null row', row: null },
{ name: 'an undefined row', row: undefined },
Expand DownExpand Up@@ -269,7 +306,65 @@ describe('human-user predicate agreement — plugin-security `isHumanUser` vs pl
}
});

for (const { name, row } of CORPUS) {
for (const { name, row, idLessFailClosed } of CORPUS) {
if (idLessFailClosed) {
/**
* [#14348] The one corpus row this probe cannot read a predicate verdict
* from — and why that is NOT a predicate disagreement.
*
* Both owners call `{ email: 'ghost@example.test' }` HUMAN, and they
* still agree: nothing in #14348 touched either predicate. What changed
* is the PROXY. Promotion is now "human AND can authenticate", and the
* second conjunct is unanswerable for a row with no `id`: there is no key
* to hang a `sys_account` on, so no account can exist and none can be
* modelled above. Reading `adminPromoted` here would therefore report the
* missing conjunct as a missing predicate agreement.
*
* So this row asserts the OUTCOME instead, and the outcome is
* fail-closed on purpose. A row with no `id` cannot hold an exercisable
* grant: the pre-#14348 code promoted it by writing
* `sys_user_permission_set.user_id = undefined` — a grant addressed to
* nobody, in the table whose whole job is to say who may administer the
* platform. Refusing it is the same direction this file's own
* NON_OBJECT_CORPUS already fixed ("for a promotion predicate the safe
* answer to malformed input is no"), applied to the one malformed shape
* that is a real object.
*
* ⛔ This is NOT licence to relax the agreement assertion for any other
* row. Every id-bearing row still proves the two predicates agree, and
* `no_authenticable_user` is asserted below precisely so this case cannot
* pass on a harness that failed earlier for some unrelated reason.
*/
it(`fails closed on ${name} — id-less, so no account can key to it (#14348)`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);

// The predicates still agree that this row is human: asserted on the
// owner side so a regression there cannot hide behind this case.
expect(
authSays,
'plugin-auth isHumanUserRow must still call an id-less human row HUMAN',
).toBe(true);

// ...and promotion still refuses it, for the second conjunct.
expect(
security.human,
`an id-less row must NOT be promoted: the grant row it would write is\n` +
`addressed to \`user_id: undefined\`, which no principal can ever exercise.\n` +
` row: ${JSON.stringify(row)}\n` +
` reason: ${security.reason ?? 'none'}`,
).toBe(false);

// Prove the refusal came from the authenticable filter and not from an
// earlier branch — the same anti-vacuity discipline the loop below uses.
expect(
security.reason,
'refusal did not come from the authenticable filter',
).toBe('no_authenticable_user');
});
continue;
}

it(`agrees on ${name}`, async () => {
const authSays = isHumanUserRow(row);
const security = await securityVerdict(row);
Expand Down
Loading
Loading