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
19 changes: 19 additions & 0 deletions .changeset/20751-services-strings-stage5-state-the-decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/plugin-security': patch
---

Security refusals, explain details, field help and log lines no longer cite tracker numbers; each one states the decision behind it in words

Clause-②: no

Some strings the security plugin shows to administrators, authors and operators pointed at an issue-tracker number for the reason behind them. The number goes; where the sentence did not already say what was decided, it now does.

- The curated capability-name refusal says a curated name is refused at authoring so that no admin-authored row can collide with the row the platform seeds for it.
- The two delegation anchor refusals say the business-unit anchor roots the delegate's business-unit visibility, so a delegation may only narrow it.
- The `managed_by` field help on `sys_permission_set` and `sys_position`, in every shipped locale, says capabilities, permission sets and positions all share one platform / package / admin vocabulary.
- The explain details for an unresolvable security posture and for the View/Modify All Data bypass drop their citations; those sentences already said that access fails closed and that the write path consults the same bypass.
- The derived-capability boot warning says the derivation refreshes a row's label and description only when it can prove the row is the platform's own, and that the seeder neither adopts a row it cannot prove is its own nor backfills provenance on the operator's behalf.
- The fail-closed log lines say what each denial protects: a `controlled_by_parent` child is readable and writable only where its master is, and a chain the derivation cannot resolve admits no child; only a resolved sharing allow (Modify All Data or an edit-level share) may replace the platform ownership floor; an authored-policy verdict that cannot be resolved never lifts the sharing refusal; a path that bypasses the engine middleware never runs without the owner and share scope a direct read applies; a delegated read is never scoped wider than its delegator's own; an unreadable posture never defaults to public or uncontracted.
- The public-form line says an anonymous submission cannot set ownership, tenancy or audit columns; the uninstall line says a package's permission rows are removed by `package_id`, so no grant outlives the package; the platform-owner wall-bypass line says only the declared platform owner's reads cross the wall and writes stay walled for everyone. The org-scoping entitlement, masking-rule, permission-set resolution, vocabulary-normalization and service-registration lines drop their citations, and the log lines that carried a tracker number in their `[security/…]` prefix now open with `[security]`.

Text only: no status, error code, field, route or control flow moves. A client or log filter that matches the old text (for example a tracker-number suffix or prefix) needs the new spelling.
Original file line number Diff line number Diff line change
Expand Up @@ -746,14 +746,17 @@ export async function bootstrapSystemCapabilities(
'at all), so it most likely arrived as app seed data replayed per organization, or a legacy ' +
'import. Fix it AT ITS SOURCE: Setup cannot, because ADR-0066 asset ownership refuses every ' +
'admin-door edit and delete on a platform-stamped row. Note the platform bucket stays empty ' +
"either way — that is the #8552 posture for an occupied name, not a consequence of the stamp."
'either way, and not because of the stamp: when a row the seeder cannot prove is its own ' +
'already holds the name, the seeder declines rather than adopting that row or backfilling a stamp.'
: " The organization's row is a supported extension (ADR-0066 D1 — admins EXTEND the " +
'registry), so there is nothing for an operator to remove; the platform bucket is left empty ' +
'deliberately, and adopting or backfilling it was rejected in #8552.';
'deliberately: the seeder does not adopt a row it cannot prove is its own, and does not ' +
"backfill provenance on the operator's behalf.";
options.logger?.warn?.(
`[security] derived capability "${def.name}" has no platform placeholder and none was seeded. ` +
`The row this pass found for the name is ${provenance} ${locality}, and its label and ` +
'description were left as their author wrote them (#5876 — unchanged). In the platform ' +
'description were left as their author wrote them (the derivation refreshes them only on a row ' +
"it can prove is the platform's own). In the platform " +
`(NULL-organization) bucket, where the declared unique key admits one row per name: ${bucket}. ` +
"The platform's own derived placeholder is therefore missing from sys_capability " +
'installation-wide. Grants and requiredPermissions referencing the name are unaffected — ' +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export async function cleanupPackagePermissions(
);
}
if (out.sets + out.positionBindings + out.userGrants + out.suggestions > 0) {
logger?.info?.('[security] package permission rows revoked on uninstall (#2747)', {
logger?.info?.('[security] package permission rows revoked on uninstall — removed by package_id, so no grant outlives the package (ADR-0090 D5)', {
packageId, ...out,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('[#10556 (a)] SecurityPlugin default report sink', () => {
expect(filter).toBeDefined();

expect(seen).toHaveLength(1);
expect(String(seen[0]?.[0])).toContain('denying (fail-closed, #2852)');
expect(String(seen[0]?.[0])).toContain("denying (fail-closed: a delegated read is never scoped wider than its delegator's own)");
});

it('guarantees a `warn` channel on the default sink, and routes it to the console', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/plugin-security/src/delegated-admin-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,8 +621,8 @@ export class DelegatedAdminGate {
if (!allowed.has(rowAnchor)) {
deny(
allowed.size === 0
? `business unit anchor '${rowAnchor}' cannot be validated against your own '${positionName}' anchor — an anchor that cannot be proven within your own range is refused (cloud#830: anchoring only narrows)`
: `business unit anchor '${rowAnchor}' is outside your own effective anchor for '${positionName}' — a delegation may only narrow visibility, never widen it (cloud#830: anchoring only narrows)`,
? `business unit anchor '${rowAnchor}' cannot be validated against your own '${positionName}' anchor — an anchor that cannot be proven within your own range is refused, because the anchor roots the delegate's business-unit visibility and may only narrow yours`
: `business unit anchor '${rowAnchor}' is outside your own effective anchor for '${positionName}' — a delegation may only narrow visibility, never widen it, because the anchor roots the delegate's business-unit visibility`,
{ position: positionName, businessUnitId: rowAnchor },
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ describe('[#12699] platformGlobalObjects — the deployment carve-out', () => {
});
// And nothing to refuse means nothing to warn about.
const warned = logger.warn.mock.calls.map((c) => String(c[0]));
expect(warned.filter((m) => m.includes('#12699'))).toEqual([]);
expect(warned.filter((m) => m.includes('org-scoping entitlement key'))).toEqual([]);
});

it('composes with, never replaces, the object-level channel: `tenancy.enabled:false` stays exempt with no deployment declaration', async () => {
Expand Down
6 changes: 3 additions & 3 deletions packages/plugins/plugin-security/src/explain-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1406,7 +1406,7 @@ async function applyRecordAttribution(
rowFilter: null,
rules: [],
detail: `View/Modify All Data via [${vamaSets.join(', ')}] admits this record regardless of ownership — ` +
'the same bypass the write path consults (#4647).',
'the same bypass the write path consults.',
}
: { outcome: 'not_evaluated', rules: [], detail: 'No View/Modify All Data bypass applies to this record.' };
}
Expand Down Expand Up @@ -1865,13 +1865,13 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput
(delegatorVama ? ` AND by the delegator [${delegatorVama.join(', ')}]` : '') +
` — ownership and sharing checks are skipped` +
(vamaBit === 'modify'
? ` (Modify All Data: the write path consults this SAME bypass, #4647).`
? ` (Modify All Data: the write path consults this SAME bypass).`
: `.`)
: agentVama.length > 0 && delegatorVama !== null && delegatorVama.length === 0
? `Agent holds View/Modify All Data via [${agentVama.join(', ')}] but the DELEGATOR does not — D10 intersection strips the bypass.`
: viewOnlySets.length > 0
? `View All Data held via [${viewOnlySets.join(', ')}] does NOT bypass ownership for ${operation} — ` +
`a write bypass requires Modify All Data (modifyAllRecords), so ownership and sharing still decide (#4647).`
`a write bypass requires Modify All Data (modifyAllRecords), so ownership and sharing still decide.`
: 'No View/Modify All Data bypass.',
contributors: vamaEffective ? vamaSets.map((n) => ({ kind: 'permission_set' as const, name: n, via: viaOf(n) })) : [],
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ describe('[#10424] the wording module states three distinct things', () => {
expect(remedy).toContain('Do NOT change the declaration');
expect(remedy).toContain('NOT a permissions problem');
expect(unresolvedPostureExplainDetail('task', 'metadata_unavailable')).toContain('OUTAGE');
expect(unresolvedPostureExplainDetail('task', 'metadata_unavailable')).toContain('#3545');
expect(unresolvedPostureExplainDetail('task', 'metadata_unavailable')).toContain('rather than defaulting to public/uncontracted');
});

it('the outage denial keeps the pinned opening clause verbatim', () => {
Expand All @@ -362,7 +362,7 @@ describe('[#10424] the wording module states three distinct things', () => {
for (const c of causes) {
expect(unresolvedPostureDenialMessage('task', 'find', c)).toContain('[Security] Access denied:');
expect(unresolvedPostureExplainDetail('task', c)).toContain('fails CLOSED');
expect(unresolvedPostureLogLine('task', 'find', 'u1', c)).toContain('fail-closed, #3545');
expect(unresolvedPostureLogLine('task', 'find', 'u1', c)).toContain('fail-closed: an unreadable posture never defaults to public');
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export async function normalizeManagedByVocab(
const permissionSets = await normalizeObject(ql, 'sys_permission_set', PERMISSION_SET_MAP, options.logger);
const total = positions + permissionSets;
if (total > 0) {
options.logger?.info?.('[security] managed_by vocab normalized to platform/package/admin (A4 #2920)', {
options.logger?.info?.('[security] managed_by vocab normalized to platform/package/admin, the one vocabulary every RBAC catalog shares', {
positions,
permissionSets,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,8 @@ export const SysPermissionSet = ObjectSchema.create({
readonly: true,
defaultValue: 'admin',
description:
"Record provenance (unified tri-state, A4 #2920): 'platform' = shipped by the " +
"Record provenance, on the one platform / package / admin vocabulary that capabilities, " +
"permission sets and positions all share: 'platform' = shipped by the " +
"platform; 'package' = versioned package metadata (re-seeded on upgrade, read-mostly " +
"for admins); 'admin' = created/owned in this environment by an administrator " +
"(live-edited, never touched by package seeding). Legacy rows may carry 'user' (== admin).",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,8 @@ export const SysPosition = ObjectSchema.create({
readonly: true,
defaultValue: 'admin',
description:
'Record provenance (unified tri-state, A4 #2920): platform = framework built-in ' +
'Record provenance, on the one platform / package / admin vocabulary that capabilities, ' +
'permission sets and positions all share: platform = framework built-in ' +
'(read-only) / package = stack/package-declared / admin = tenant-created. Legacy rows ' +
'may carry system (== platform) / config (== package) / user (== admin).',
options: [
Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/plugin-security/src/permission-evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ export class PermissionEvaluator {
} catch (e) {
allPermSets = [];
options.logger?.warn?.(
'[security] permission-set metadata list() failed — falling back to bootstrap/db sources (#2565)',
'[security] permission-set metadata list() failed — falling back to bootstrap/db sources',
{ requested: identifiers, error: (e as Error)?.message },
);
}
Expand Down Expand Up @@ -555,7 +555,7 @@ export class PermissionEvaluator {
// DB error silently drops custom permission sets and the
// resulting 403s point nowhere near the cause (#2565).
options.logger?.warn?.(
'[security] sys_permission_set db lookup failed — unresolved sets grant nothing this request (#2565)',
'[security] sys_permission_set db lookup failed — unresolved sets grant nothing this request',
{ unresolved, error: (e as Error)?.message },
);
}
Expand Down
Loading
Loading