Skip to content

Commit 50797ce

Browse files
committed
fix(plugin-sharing): the record owner and an explicit Modify-All holder may mint a share link without visibility (ADR-0111 D8 rule 1, ruling A')
createLink admits visibility OR owner OR Modify-All bypass, behind the publicSharing opt-in and before eligibility. The owner and bypass halves are canManageShares' own first two branches (ownerOrBypass), exposed as SharingService.canMintWithoutVisibility without the hierarchy-depth branch, and withheld where an organization wall is in force. The plugin wires the probe into ShareLinkService beside canManageShares. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 120ae16 commit 50797ce

3 files changed

Lines changed: 191 additions & 52 deletions

File tree

‎packages/plugins/plugin-sharing/src/share-link-service.ts‎

Lines changed: 79 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,25 @@ export interface ShareLinkServiceOptions {
431431
recordId: string,
432432
context: ExecutionContext,
433433
) => Promise<boolean>;
434+
/**
435+
* [ADR-0111 D8 rule 1 — ruling 5950188467, A′] Late-bound mint-authority
436+
* probe (the sharing service's `canMintWithoutVisibility`): may the caller
437+
* mint on a record their visibility read refused, because they are its
438+
* OWNER or hold the explicit Modify-All bypass? `createLink` asks it only
439+
* after that read refused. It answers with `canManageShares`' own owner and
440+
* bypass branches and never with its hierarchy-depth branch, and it is
441+
* withheld where an organization wall is in force.
442+
*
443+
* Absent → visibility alone admits (the pre-A′ rule), so a deployment
444+
* without the sharing service never widens who may mint. A throwing probe
445+
* is a refusal. It receives the caller's COMPLETE envelope, like every
446+
* enforcement probe here.
447+
*/
448+
canMintWithoutVisibility?: (
449+
object: string,
450+
recordId: string,
451+
context: ExecutionContext,
452+
) => Promise<boolean>;
434453
/** [#5190] Optional logger for the record-delete cascade / orphan sweep. */
435454
logger?: {
436455
info?: (msg: any, ...rest: any[]) => void;
@@ -474,6 +493,7 @@ export class ShareLinkService implements IShareLinkService {
474493
recordId: string,
475494
context: ExecutionContext,
476495
) => Promise<boolean>;
496+
private readonly canMintWithoutVisibility?: ShareLinkServiceOptions['canMintWithoutVisibility'];
477497
private readonly logger?: ShareLinkServiceOptions['logger'];
478498
/**
479499
* [#12981] Latched by the FIRST refused usage stamp on this instance and
@@ -488,6 +508,7 @@ export class ShareLinkService implements IShareLinkService {
488508
this.hashPassword = opts.hashPassword ?? defaultHashPassword;
489509
this.verifyPassword = opts.verifyPassword ?? defaultVerifyPassword;
490510
this.canManageShares = opts.canManageShares;
511+
this.canMintWithoutVisibility = opts.canMintWithoutVisibility;
491512
this.logger = opts.logger;
492513
}
493514

@@ -501,11 +522,13 @@ export class ShareLinkService implements IShareLinkService {
501522
const schema = this.engine.getSchema?.(input.object);
502523
const policy = getPolicy(schema);
503524

504-
// [ADR-0111 D8] Mint authority = the object's `publicSharing` opt-in (this
505-
// check) AND the caller's visibility of the record (the RLS-scoped read
506-
// below). An object that opts into publicSharing deliberately delegates
507-
// re-share power to anyone who can SEE the record — a stated decision, not
508-
// an accident. Objects that do not opt in cannot be link-shared at all.
525+
// [ADR-0111 D8 rule 1] Mint authority = the object's `publicSharing` opt-in
526+
// (this check, FIRST) AND — after the request-shape checks below — the
527+
// caller's authority over the record: visibility (the RLS-scoped read), or
528+
// the record owner, or an explicit Modify-All bypass (ruling 5950188467,
529+
// A′). An object that opts into publicSharing deliberately delegates
530+
// re-share power to anyone who can SEE the record. Objects that do not opt
531+
// in cannot be link-shared at all, by their owner included.
509532
if (!policy.enabled && !this.permissive && !context.isSystem) {
510533
throw makeError(
511534
422,
@@ -537,10 +560,11 @@ export class ShareLinkService implements IShareLinkService {
537560
}
538561

539562
// Confirm the target record exists AND — for an HTTP caller — that the
540-
// caller may actually SEE it. [Finding-2] Reading under the caller's own
541-
// context (positions/permissions/RLS) means you can only mint a link for a
542-
// record you can access; a client can no longer share arbitrary rows of a
543-
// publicSharing-enabled object it cannot see. Internal (isSystem) callers
563+
// caller holds authority over it. [Finding-2] Reading under the caller's
564+
// own context (positions/permissions/RLS) means a client can no longer
565+
// share arbitrary rows of a publicSharing-enabled object it cannot see;
566+
// the only callers who mint past a refused read are the record's owner and
567+
// an explicit Modify-All holder (A′, below). Internal (isSystem) callers
544568
// read under the system context as before.
545569
//
546570
// [commit 8e13ca876] `context` is passed through UNCHANGED — it is the caller's whole
@@ -556,32 +580,59 @@ export class ShareLinkService implements IShareLinkService {
556580
// a second query being issued, and it widens ONLY then, so an object
557581
// without the key keeps the exact `id`-only read it always had.
558582
const eligibility = policy.enabled ? policy.eligibility : undefined;
559-
const exists = await this.engine.find(input.object, {
560-
where: { id: input.recordId },
561-
...(eligibility ? {} : { fields: ['id'] }),
562-
limit: 1,
563-
context: context.isSystem ? SYSTEM_CTX : context,
564-
} as any);
565-
if (!Array.isArray(exists) || exists.length === 0) {
583+
const readRecord = async (readContext: ExecutionContext | typeof SYSTEM_CTX) => {
584+
const rows = await this.engine.find(input.object, {
585+
where: { id: input.recordId },
586+
...(eligibility ? {} : { fields: ['id'] }),
587+
limit: 1,
588+
context: readContext,
589+
} as any);
590+
return Array.isArray(rows) ? (rows[0] as Record<string, unknown> | undefined) : undefined;
591+
};
592+
593+
// Visibility first. A refusal the read THROWS (the CRUD gate's, on an
594+
// object the caller holds no read grant on) is kept, not swallowed: it is
595+
// re-thrown below unless an alternative admits, so a caller refused before
596+
// A′ is refused with the same envelope after it.
597+
let record: Record<string, unknown> | undefined;
598+
let visibilityRefusal: unknown;
599+
try {
600+
record = await readRecord(context.isSystem ? SYSTEM_CTX : context);
601+
} catch (err) {
602+
if (context.isSystem) throw err;
603+
visibilityRefusal = err;
604+
}
605+
606+
// [ruling 5950188467, A′] The owner and Modify-All alternatives, asked only
607+
// once visibility has refused. The probe is the sharing service's
608+
// `canMintWithoutVisibility`: `canManageShares`' owner and bypass branches
609+
// WITHOUT its hierarchy-depth branch — a hierarchy manager still needs
610+
// visibility to mint — and withheld under an organization wall. Admitted,
611+
// the record is read under the system context: the caller's authority is
612+
// established, and the eligibility gate below must judge the row the
613+
// anonymous holder will be served (`resolveToken` reads it the same way).
614+
if (!record && !context.isSystem && this.canMintWithoutVisibility) {
615+
const admitted = await this.canMintWithoutVisibility(input.object, input.recordId, context)
616+
.catch(() => false);
617+
if (admitted) record = await readRecord(SYSTEM_CTX);
618+
}
619+
620+
if (!record) {
621+
if (visibilityRefusal !== undefined) throw visibilityRefusal;
566622
// Don't distinguish "missing" from "not visible" for an untrusted caller.
567623
throw context.isSystem
568624
? makeError(404, 'RECORD_NOT_FOUND', `${input.object}/${input.recordId} does not exist`)
569625
: makeError(403, 'FORBIDDEN', `Not permitted to share ${input.object}/${input.recordId}`);
570626
}
571627

572-
// [#7861] The declared eligibility gate. Placed AFTER the visibility read
573-
// (it needs the record, and a caller who cannot see the row must not learn
574-
// anything about its contents from the refusal) and BEFORE the insert, so
575-
// an ineligible link is never MINTED — which is the only placement that
576-
// helps, since `resolveToken` serves an existing row anonymously under
577-
// `SYSTEM_CTX` with no auth check to fall back on.
628+
// [#7861] The declared eligibility gate. Placed AFTER the authority check
629+
// (it needs the record, and a caller with no authority over the row must
630+
// not learn anything about its contents from the refusal) and BEFORE the
631+
// insert, so an ineligible link is never MINTED — which is the only
632+
// placement that helps, since `resolveToken` serves an existing row
633+
// anonymously under `SYSTEM_CTX` with no auth check to fall back on.
578634
if (eligibility) {
579-
assertEligible(
580-
eligibility,
581-
(exists[0] ?? {}) as Record<string, unknown>,
582-
schema,
583-
input.object,
584-
);
635+
assertEligible(eligibility, record, schema, input.object);
585636
}
586637

587638
const maxDays = policy.maxExpiryDays ?? DEFAULT_MAX_EXPIRY_DAYS;

‎packages/plugins/plugin-sharing/src/sharing-plugin.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,13 @@ export class SharingServicePlugin implements Plugin {
869869
canManageShares: this.service
870870
? (o, r, c) => this.service!.canManageShares(o, r, c as any)
871871
: undefined,
872+
// [ADR-0111 D8 rule 1, ruling 5950188467 (A′)] The record's owner and
873+
// an explicit Modify-All holder may mint where the visibility read
874+
// refuses them: `canManageShares`' own owner and bypass branches,
875+
// without its hierarchy-depth branch.
876+
canMintWithoutVisibility: this.service
877+
? (o, r, c) => this.service!.canMintWithoutVisibility(o, r, c as any)
878+
: undefined,
872879
});
873880
ctx.registerService('shareLinks', this.linkService);
874881

‎packages/plugins/plugin-sharing/src/sharing-service.ts‎

Lines changed: 105 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,17 @@ export interface SharingServiceOptions {
365365
};
366366
}
367367

368+
/**
369+
* [ADR-0111 D1] What `SharingService.ownerOrBypass` concluded about a record:
370+
* the caller owns it or holds the Modify-All bypass (`admit`), there is
371+
* nothing to decide on (`refuse`), or neither branch applies and the record's
372+
* `owner` is handed on to the DEPTH branch (`undecided`).
373+
*/
374+
type OwnerOrBypassVerdict =
375+
| { readonly kind: 'admit' }
376+
| { readonly kind: 'refuse' }
377+
| { readonly kind: 'undecided'; readonly owner: unknown };
378+
368379
/**
369380
* Default `ISharingService` implementation.
370381
*
@@ -950,31 +961,15 @@ export class SharingService implements ISharingService {
950961
context: ExecutionContext,
951962
): Promise<boolean> {
952963
if (context?.isSystem) return true;
953-
if (!object || !recordId || !context?.userId) return false;
954964

955-
// Ownership — read under system context so field-level masking cannot
956-
// hide the owner column from the decision itself. Keep the owner value:
957-
// the DEPTH branch below reuses it rather than re-reading the row.
958-
let owner: unknown;
959-
try {
960-
const rows = await this.engine.find(object, {
961-
where: { id: recordId },
962-
fields: ['id', OWNER_FIELD],
963-
limit: 1,
964-
context: SYSTEM_CTX,
965-
});
966-
const row: any = Array.isArray(rows) ? rows[0] : undefined;
967-
if (!row) return false;
968-
owner = row[OWNER_FIELD];
969-
if (owner != null && String(owner) === String(context.userId)) return true;
970-
} catch {
971-
return false;
972-
}
973-
974-
// Modify All Data — the EXPLICIT bypass only (ADR-0111 D1/D2; never the
975-
// effective write scope, whose unmatched-object case fails open to 'org').
976-
// [#4647] Shared with `canEdit`/`canDelete` so the three gates cannot drift.
977-
if (await this.hasModifyAllBypass(object, context)) return true;
965+
// The record owner and the Modify-All bypass, read in `ownerOrBypass` —
966+
// the two branches this gate shares with mint authority
967+
// (`canMintWithoutVisibility`). A verdict there is final; `undecided`
968+
// hands the owner value on, so the DEPTH branch below does not re-read
969+
// the row.
970+
const direct = await this.ownerOrBypass(object, recordId, context);
971+
if (direct.kind !== 'undecided') return direct.kind === 'admit';
972+
const owner = direct.owner;
978973

979974
const probe = this.securityService?.();
980975

@@ -1001,6 +996,92 @@ export class SharingService implements ISharingService {
1001996
return false;
1002997
}
1003998

999+
/**
1000+
* [ADR-0111 D8 rule 1 — ruling 5950188467, A′] May `context` mint a share
1001+
* link on `(object, recordId)` WITHOUT being able to see it?
1002+
*
1003+
* Mint authority is "visibility, or the record owner, or an explicit
1004+
* Modify-All bypass". The link service runs the visibility read itself; this
1005+
* answers the other two alternatives, and it answers them with
1006+
* {@link canManageShares}' own first two branches (`ownerOrBypass`) — the
1007+
* same owner column and the same `hasWriteBypass` probe, so there is one
1008+
* notion of ownership, not two.
1009+
*
1010+
* ## What it deliberately leaves out
1011+
*
1012+
* **The hierarchy-depth branch.** A manager whose write DEPTH covers the
1013+
* owner manages the record's shares (revoke, grant, list — D8 rule 2), but a
1014+
* link CREATES access, and that manager may hold write depth over a record
1015+
* the data door will not let them read. Admitting them would let minting
1016+
* outrun reading; so a hierarchy manager still needs visibility to mint. This
1017+
* method never calls `resolveWriteScope` or the hierarchy resolver.
1018+
*
1019+
* **A deployment that walls organizations.** Under the `group` / `isolated`
1020+
* postures the visibility read applies Layer 0 (ADR-0095 D1 / ADR-0105 D1),
1021+
* and neither alternative here knows the record's organization: the owner
1022+
* column outlives a membership, and `hasWriteBypass` is object-wide. So where
1023+
* a wall is in force both alternatives are withheld and visibility alone
1024+
* admits — a member who left an organization cannot mint a public link to a
1025+
* record they still own in it. The posture is the one
1026+
* {@link organizationScopeRequired} reads, fail-closed: an unresolvable
1027+
* posture counts as walled.
1028+
*
1029+
* Everything else fails CLOSED to `false`: a missing record, a
1030+
* principal-less context, a failed read or probe.
1031+
*/
1032+
async canMintWithoutVisibility(
1033+
object: string,
1034+
recordId: string,
1035+
context: ExecutionContext,
1036+
): Promise<boolean> {
1037+
if (this.organizationScopeRequired()) return false;
1038+
return (await this.ownerOrBypass(object, recordId, context)).kind === 'admit';
1039+
}
1040+
1041+
/**
1042+
* [ADR-0111 D1] The record OWNER and the explicit Modify-All bypass — the
1043+
* branches {@link canManageShares} and {@link canMintWithoutVisibility} both
1044+
* read, written once.
1045+
*
1046+
* `admit`: the caller owns the record, or holds `modifyAllRecords` on the
1047+
* object. `refuse`: there is nothing to decide on — no object, record or
1048+
* user identity, no such record, or the owner read failed. `undecided`: the
1049+
* record exists and the caller is neither; `owner` is carried for the DEPTH
1050+
* branch, which only `canManageShares` consults.
1051+
*
1052+
* Ownership is read under the system context so field-level masking cannot
1053+
* hide the owner column from the decision itself. The bypass is the EXPLICIT
1054+
* one only (ADR-0111 D1/D2; never the effective write scope, whose
1055+
* unmatched-object case fails open to 'org'), shared with `canEdit` /
1056+
* `canDelete` [#4647] so the gates cannot drift.
1057+
*/
1058+
private async ownerOrBypass(
1059+
object: string,
1060+
recordId: string,
1061+
context: ExecutionContext,
1062+
): Promise<OwnerOrBypassVerdict> {
1063+
if (!object || !recordId || !context?.userId) return { kind: 'refuse' };
1064+
1065+
let owner: unknown;
1066+
try {
1067+
const rows = await this.engine.find(object, {
1068+
where: { id: recordId },
1069+
fields: ['id', OWNER_FIELD],
1070+
limit: 1,
1071+
context: SYSTEM_CTX,
1072+
});
1073+
const row: any = Array.isArray(rows) ? rows[0] : undefined;
1074+
if (!row) return { kind: 'refuse' };
1075+
owner = row[OWNER_FIELD];
1076+
if (owner != null && String(owner) === String(context.userId)) return { kind: 'admit' };
1077+
} catch {
1078+
return { kind: 'refuse' };
1079+
}
1080+
1081+
if (await this.hasModifyAllBypass(object, context)) return { kind: 'admit' };
1082+
return { kind: 'undecided', owner };
1083+
}
1084+
10041085
/**
10051086
* [ADR-0111 D5] Is `(object, recordId)` VISIBLE to the caller? Reads under
10061087
* the CALLER's own context so the security RLS and sharing read filters

0 commit comments

Comments
 (0)