Skip to content
17 changes: 17 additions & 0 deletions .changeset/24343-producers-name-organization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/plugin-audit": minor
"@objectstack/service-settings": minor
"@objectstack/plugin-auth": patch
"@objectstack/verify": patch
---

fix: framework writes on tenant-column objects name the organization they can derive (ADR-0131 D9)

Clause-②: yes (widening)

A write on a tenant-column object with no organization is derived under `single` and refused under a wall (ADR-0131 D9). Before the engine stops letting such writes through, each of these producers now names the organization it can derive itself. Under `single` that is the Default Organization, which is the owner the engine would derive anyway, so no reader changes. Under a wall the row names an organization only where the producer has one. The rows it cannot attribute are written exactly as before.

- **`@objectstack/plugin-auth`.** The self-registration grant (`sys_user_permission_set`) and the auth mails about a user (verification, password reset, account invitation, magic link, change-email notice → `sys_email`) read one derivable organization: the `tenancy` service's `defaultOrgId()`. Under `single` that is the Default Organization. Under a wall it is none, because the registrant belongs to no organization yet. The verification and reset mails used to persist with no organization even under `single`.
- **`@objectstack/plugin-audit`.** An activity row (`sys_activity`) about a record with no organization of its own (a `sys_user`, a platform setting), written with no session organization (a sign-up), takes the Default Organization under `single`. The record's own organization and then the session's still win. The ledger's `tenant_id` attribution is unchanged. New optional `AuditWriterOptions.getDefaultOrganizationId()`; `AuditPlugin` wires it to the `tenancy` service.
- **`@objectstack/service-settings`.** The `sys_secret` ciphertext and the `sys_setting_audit` history row of a `tenant` or `user` value carry that value's organization, which is the same owner as its `sys_setting` row. A `global` value's rows name none. New optional `organization_id` on `SettingsSecretStore.insert`'s row and `organizationId` on `SettingsAuditWriter.write`'s entry; the built-in writers persist them. A failed `sys_setting_audit` write is now reported at `error`, not `warn`: the settings write succeeds while its history row is lost. The first report names the consequence and the fix. `buildSettingAuditWriter`'s optional logger argument keeps every sink it accepted (none, `{}`, or one with an optional `warn`) and additionally takes a sink that carries `error`. It reports through `error` when the sink has it, else through `warn`.
- **`@objectstack/verify`.** The RLS probe and position personas' grants (`sys_user_permission_set`, `sys_user_position`) belong to the organization the persona is a member of (the Default Organization under `single`). They used to be written with an explicit `organization_id: null`. A persona that belongs to no organization, or to several, names none.
15 changes: 14 additions & 1 deletion packages/plugins/plugin-audit/src/audit-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,20 @@ export class AuditPlugin implements Plugin {
const { locale } = await resolveLocalizationContext({ ql: engine, settings, tenantId, userId });
return locale;
};
installAuditWriters(engine as any, this.name, { getMessaging, getI18n, getLocale });
// [ADR-0131 D9] The organization an activity row about an org-less record
// derives: the `tenancy` service's Default Organization under `single`,
// none under a wall. Resolved at write time — AuthPlugin registers the
// service, and the answer is memoized there.
const getDefaultOrganizationId = async (): Promise<string | null | undefined> => {
let tenancy: { defaultOrgId?: () => Promise<string | null> } | undefined;
try {
tenancy = ctx.getService<{ defaultOrgId?: () => Promise<string | null> }>('tenancy');
} catch {
return undefined;
}
return typeof tenancy?.defaultOrgId === 'function' ? tenancy.defaultOrgId() : undefined;
};
installAuditWriters(engine as any, this.name, { getMessaging, getI18n, getLocale, getDefaultOrganizationId });
ctx.logger.info('AuditPlugin: audit + activity writers installed');

// [#8992] Record-view auditing — the `read` half of the ledger. Installed
Expand Down
58 changes: 58 additions & 0 deletions packages/plugins/plugin-audit/src/audit-writers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2564,3 +2564,61 @@ describe('[#21207] stored metadata copies carry no content hash', () => {
expect(JSON.parse(audit!.row.new_value).checksum).toBe(HASH);
});
});

/**
* [ADR-0131 D9] An activity row about a record with no organization of its own
* (a `sys_user`, a platform setting), written with no session organization (a
* sign-up), takes the one the host derives — the `tenancy` service's Default
* Organization under `single`, `null` under a wall. The ledger row's
* `tenant_id` is an attribution of what the row is ABOUT (D7) and is not
* touched; a known organization is never overridden.
*/
describe('audit writers — the activity row of an org-less record names the derivable organization (ADR-0131 D9)', () => {
const ORG_LESS = { ...MULTI_TENANT, sys_user: ['id', 'name', 'email'] };
const signUp = {
object: 'sys_user',
input: { id: 'usr-1' },
result: { id: 'usr-1', name: 'Ada', email: 'ada@example.com' },
session: {},
};

it('stamps the Default Organization under single, and leaves the ledger attribution alone', async () => {
const { engine, fire, created } = makeEngine(ORG_LESS);
installAuditWriters(engine as any, 'test.audit', { getDefaultOrganizationId: async () => 'org-default' });
await fire('afterInsert', signUp);

expect(created.find((c) => c.object === 'sys_activity')?.row.organization_id).toBe('org-default');
expect(created.find((c) => c.object === 'sys_audit_log')?.row.tenant_id).toBeNull();
});

it('names none under a wall, and none when the host derives nothing or cannot answer', async () => {
for (const getDefaultOrganizationId of [async () => null, undefined, async () => { throw new Error('no tenancy'); }]) {
const { engine, fire, created } = makeEngine(ORG_LESS);
installAuditWriters(engine as any, 'test.audit', getDefaultOrganizationId ? { getDefaultOrganizationId } : {});
await fire('afterInsert', signUp);
expect(created.find((c) => c.object === 'sys_activity')?.row.organization_id).toBeNull();
}
});

it('never overrides a known organization — the record\'s, then the session\'s — nor asks for one', async () => {
let asked = 0;
const getDefaultOrganizationId = async () => { asked += 1; return 'org-default'; };
const { engine, fire, created } = makeEngine({ ...MULTI_TENANT, crm_lead: ['id', 'name', 'organization_id'] });
installAuditWriters(engine as any, 'test.audit', { getDefaultOrganizationId });
await fire('afterInsert', {
object: 'crm_lead',
input: { id: 'lead-1' },
result: { id: 'lead-1', name: 'Acme', organization_id: 'org-A' },
session: { organizationId: 'org-B', userId: 'user-1' },
});
await fire('afterInsert', {
object: 'crm_lead',
input: { id: 'lead-2' },
result: { id: 'lead-2', name: 'Beta', organization_id: null },
session: { organizationId: 'org-B', userId: 'user-1' },
});
const activities = created.filter((c) => c.object === 'sys_activity').map((c) => c.row.organization_id);
expect(activities).toEqual(['org-A', 'org-B']);
expect(asked).toBe(0);
});
});
25 changes: 24 additions & 1 deletion packages/plugins/plugin-audit/src/audit-writers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ export interface AuditWriterOptions {
* comes first. Absent → summaries stay English (status quo).
*/
getLocale?(tenantId?: string, userId?: string): Promise<string | undefined>;
/**
* [ADR-0131 D9] The organization an activity row carries when neither its
* record nor the acting session names one: the Default Organization under
* `single` (the only organization there is), `null` under a wall — the
* `tenancy` service's `defaultOrgId()`. Asked only for such a row, never
* for one whose organization is already known. Absent, or an answer that
* throws → the row names none, as before.
*/
getDefaultOrganizationId?(): Promise<string | null | undefined>;
}

/**
Expand Down Expand Up @@ -1445,6 +1454,14 @@ export function installAuditWriters(
// `resolveRecordOrganizationField` there for the precedence and for why the
// answer comes from the schema rather than a literal.
const recordOrgResolver = createRecordOrganizationResolver(engine);
const derivableActivityOrganization = async (): Promise<string | undefined> => {
if (!opts.getDefaultOrganizationId) return undefined;
try {
return (await opts.getDefaultOrganizationId()) ?? undefined;
} catch {
return undefined;
}
};

// Display label for an object under a given translate fn: translated label
// → authored def label → API name. Shared by activity summaries and the
Expand Down Expand Up @@ -2204,8 +2221,14 @@ export function installAuditWriters(
// Same rationale as auditRow: stamp the tenant column so RLS matches the
// recipient's organization on read — but only when the (auto-injected)
// column actually exists, so single-tenant activity writes don't fail.
//
// [ADR-0131 D9] A record with no organization of its own (a `sys_user`, a
// platform setting), written with no session organization (a sign-up),
// takes the derivable one: the Default Organization under `single`. Only
// the activity row: the ledger's `tenant_id` above is an attribution of
// what the row is ABOUT (D7) and stays the record's.
if (objectHasField('sys_activity', 'organization_id')) {
activityRow.organization_id = tenantId ?? null;
activityRow.organization_id = tenantId ?? (await derivableActivityOrganization()) ?? null;
}

const activityRowToWrite = mirrorsActivity ? activityRow : undefined;
Expand Down
53 changes: 42 additions & 11 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1916,6 +1916,7 @@ export class AuthManager {
// the user's mail carries only the deployment default. Best-effort;
// `undefined` leaves the #14319 ladder exactly as it was.
const storedLocale = await this.storedRecipientLocale({ id: user.id });
const mailOrganization = await this.authMailOrganizationArg();
const result = await email.sendTemplate(invitation
? {
// Exactly the variables `auth.account_invitation` declares —
Expand All @@ -1935,6 +1936,7 @@ export class AuthManager {
},
relatedObject: 'sys_user',
relatedId: user.id,
...mailOrganization,
}
: {
template: 'auth.password_reset',
Expand All @@ -1949,6 +1951,7 @@ export class AuthManager {
},
relatedObject: 'sys_user',
relatedId: user.id,
...mailOrganization,
});
if (result?.status === 'failed') {
throw new Error(
Expand Down Expand Up @@ -2011,6 +2014,9 @@ export class AuthManager {
},
relatedObject: 'sys_user',
relatedId: user.id,
// [ADR-0131 D9] The mail is about a user, who belongs to no
// organization yet: the derivable owner, as for the grant.
...(await this.authMailOrganizationArg()),
});
if (result?.status === 'failed') {
throw new Error(
Expand Down Expand Up @@ -4012,6 +4018,7 @@ export class AuthManager {
expiresInMinutes: 10,
appName: this.getAppName(),
},
...(await this.authMailOrganizationArg()),
});
} catch (err: any) {
console.error(`[AuthManager] sendMagicLink failed: ${err?.message ?? err}`);
Expand Down Expand Up @@ -5516,12 +5523,41 @@ export class AuthManager {
}
}

/**
* [ADR-0131 D9] The organization a write this manager makes on a
* tenant-column object carries when nothing on the write names one: the
* Default Organization under `single` — the only organization there is —
* and none under a wall, where `tenancy.defaultOrgId()` answers `null`. One
* reading for every such producer here (the self-registration grant and the
* auth mails that are about a user rather than an organization), so they
* cannot disagree about it. `undefined` when no tenancy service is wired or
* it cannot answer: the producer then names nothing, as before.
*/
private async derivableWriteOrganizationId(): Promise<string | undefined> {
try {
const tenancy = this.config.getTenancy?.();
const organizationId = tenancy ? await tenancy.defaultOrgId() : null;
return organizationId ?? undefined;
} catch {
return undefined;
}
}

/** The `organizationId` argument of an auth mail that is about a user, not an organization. */
private async authMailOrganizationArg(): Promise<{ organizationId?: string }> {
const organizationId = await this.derivableWriteOrganizationId();
return organizationId ? { organizationId } : {};
}

/**
* Land the staged self-registration grant for a freshly created user — the
* `user.create.after` half of an admitted self-registration. Idempotent
* (existing `(user, set)` pair wins), org-scoped to the same target org the
* membership reconciler resolves (falls back to an unscoped row only when
* no org is resolvable — multi-org self-registration binds no org either).
* (existing `(user, set)` pair wins), owned by the organization
* {@link derivableWriteOrganizationId} derives — the same target org the
* membership reconciler resolves. Under a wall none is derivable when the
* registrant is created (multi-org self-registration binds no org either),
* so the row is written without one and its fate is the engine's (ADR-0131
* D9).
*
* A failure here is reported at the DURABILITY level (`error` when the sink
* carries it): the system keeps looking normal from the outside while a
Expand All @@ -5545,13 +5581,7 @@ export class AuthManager {
return;
}
const sys = withSystemReadContext(engine) as any;
let organizationId: string | null = null;
try {
const tenancy = this.config.getTenancy?.();
organizationId = tenancy ? await tenancy.defaultOrgId() : null;
} catch {
organizationId = null;
}
const organizationId = await this.derivableWriteOrganizationId();
// [ADR-0131 D3/D4] The grant names the declared set BY NAME — the name
// admission asked the security catalog and the activation ledger about —
// so no `sys_permission_set` row is read to write it, and none needs to
Expand All @@ -5573,7 +5603,7 @@ export class AuthManager {
this.config.logger?.info?.('[audience] granted the declared self-registration permission set', {
userId,
permissionSet: staged.setName,
organizationId: organizationId ?? undefined,
organizationId,
});
} catch (error) {
this.reportUngrantedSelfRegistrant(
Expand Down Expand Up @@ -5692,6 +5722,7 @@ export class AuthManager {
},
relatedObject: 'sys_user',
...(from.id ? { relatedId: from.id } : {}),
...(await this.authMailOrganizationArg()),
});
} catch (err) {
// Best-effort by design — see the ⛔ above. Logged so an operator can see
Expand Down
Loading
Loading