fix(ai): require member identity within a shared org for report routing, overlays, and coverage
Codex review round 7 on e90a72bdd flagged that broker usage-report matching, header-overlay keying, and omp-usage coverage all treated a matching organization as a sufficient match. Two Team members share the org id while drawing on per-user pools, so the first same-org report (or a lone sibling report) was handed to the wrong member. The org is now a gate: within the same-org subset the member's own base identity (account/email/project) must still match, with org-only entities (no base identifiers) matching on the org alone when unambiguous. The overlay merger (findMatchingReportIndex) had the identical same-org flaw and receives the symmetric fix. Org-presence-mismatch semantics are unchanged: org-scoped vs org-less stays fall-through/unreported, and both-org-less keeps the legacy base-identity fallback.
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
- Fixed usage-path OAuth refreshes on broker-backed credentials persisting the rotated token into the wrong row: the shared `REMOTE_REFRESH_SENTINEL` refresh value is no longer treated as row identity when locating the row to update, so with two same-email organizations the refreshed token lands on the org that was actually refreshed.
|
||||
- Fixed the org qualifier only riding on email-based Anthropic identities: when the login email cannot be recovered (token response omits it and the bootstrap fallback fails), the account/project fallback keys are now org-qualified too — the account UUID is identical across the orgs of one login account, so a second subscription could otherwise still replace the first on the no-email path. The same one-way legacy upgrade applies to bare account/project keys, usage-report dedupe falls back to the org-qualified account for no-email reports, and broker report routing is org-decisive on either side (an org-less legacy credential no longer receives an org-attributed sibling's pool).
|
||||
- Fixed an org-only Anthropic credential row (stored when login recovered neither email nor account) never being claimed by a later login of the same organization that does recover the identity — the row is now upgraded and re-keyed in place instead of duplicating the subscription.
|
||||
- Fixed broker usage report routing and header-overlay keying/merging to require the member's own identity (account/email/project) within a shared organization, so two Team members sharing one org id no longer receive each other's per-user pools — a member's missing report surfaces as "no usage data" instead of a sibling's numbers, while an org-only credential still matches its lone same-org report.
|
||||
|
||||
## [16.4.3] - 2026-07-11
|
||||
|
||||
|
||||
@@ -148,14 +148,19 @@ function usageOverlayKey(
|
||||
// Org first: one account email can hold several organizations (Anthropic
|
||||
// Team seat + personal Max), each with its own limit pools. Keying the
|
||||
// overlay by account/email would merge the two pools' header ingests.
|
||||
const orgId = ids.orgId?.trim().toLowerCase();
|
||||
if (orgId) return `${provider}\0org:${orgId}`;
|
||||
// But the org alone is not enough either: two Team members share the org
|
||||
// id while drawing on per-user pools, so the key stays qualified by the
|
||||
// member's own base identity whenever one is known.
|
||||
let base: string | undefined;
|
||||
const accountId = ids.accountId?.trim().toLowerCase();
|
||||
if (accountId) return `${provider}\0account:${accountId}`;
|
||||
const email = ids.email?.trim().toLowerCase();
|
||||
if (email) return `${provider}\0email:${email}`;
|
||||
const projectId = ids.projectId?.trim().toLowerCase();
|
||||
if (projectId) return `${provider}\0project:${projectId}`;
|
||||
if (accountId) base = `account:${accountId}`;
|
||||
else if (email) base = `email:${email}`;
|
||||
else if (projectId) base = `project:${projectId}`;
|
||||
const orgId = ids.orgId?.trim().toLowerCase();
|
||||
if (orgId) return base ? `${provider}\0org:${orgId}|${base}` : `${provider}\0org:${orgId}`;
|
||||
if (base) return `${provider}\0${base}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -994,28 +999,43 @@ function matchUsageReport(reports: UsageReport[], provider: Provider, credential
|
||||
// Max exhausted via Team's report, or rank a legacy row on a sibling's
|
||||
// numbers).
|
||||
const orgId = credential.orgId?.trim().toLowerCase();
|
||||
const accountId = credential.accountId?.trim().toLowerCase();
|
||||
const email = credential.email?.trim().toLowerCase();
|
||||
const projectId = credential.projectId?.trim().toLowerCase();
|
||||
if (orgId) {
|
||||
const sameOrg: UsageReport[] = [];
|
||||
let sawReportOrg = false;
|
||||
for (const report of all) {
|
||||
const metaOrg = readMetadataString((report.metadata ?? {}) as Record<string, unknown>, "orgId");
|
||||
if (metaOrg) {
|
||||
sawReportOrg = true;
|
||||
if (metaOrg.toLowerCase() === orgId) return report;
|
||||
if (metaOrg.toLowerCase() === orgId) sameOrg.push(report);
|
||||
}
|
||||
}
|
||||
// Org-attributed reports exist but none is ours: report "no usage data"
|
||||
// rather than mis-attributing another org's pool. When NO report carries
|
||||
// an org (legacy broker aggregate), fall through with all candidates.
|
||||
if (sawReportOrg) return null;
|
||||
// Org-attributed reports exist: the shared org is a GATE, not a match.
|
||||
// Two Team members share the org id while drawing on per-user pools,
|
||||
// so the credential's own base identity must still line up inside the
|
||||
// same-org subset — a lone sibling report is NOT ours. An org-only
|
||||
// credential (no base identifiers) takes the lone same-org report and
|
||||
// treats several as ambiguous. None in our org → "no usage data"
|
||||
// rather than mis-attributing another org's pool. When NO report
|
||||
// carries an org (legacy broker aggregate), fall through with all
|
||||
// candidates.
|
||||
if (sawReportOrg) {
|
||||
if (accountId || email || projectId) {
|
||||
for (const report of sameOrg) {
|
||||
if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return sameOrg.length === 1 ? sameOrg[0]! : null;
|
||||
}
|
||||
}
|
||||
const candidates = orgId
|
||||
? all
|
||||
: all.filter(report => !readMetadataString((report.metadata ?? {}) as Record<string, unknown>, "orgId"));
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
const accountId = credential.accountId?.trim().toLowerCase();
|
||||
const email = credential.email?.trim().toLowerCase();
|
||||
const projectId = credential.projectId?.trim().toLowerCase();
|
||||
for (const report of candidates) {
|
||||
if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
|
||||
}
|
||||
@@ -1029,19 +1049,33 @@ function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport):
|
||||
if (all.length === 0) return -1;
|
||||
const metadata = (overlay.metadata ?? {}) as Record<string, unknown>;
|
||||
// Org precedence — mirror matchUsageReport: an org-attributed overlay may
|
||||
// only merge into the report of the SAME org, and an org-less overlay may
|
||||
// only merge into an org-less report.
|
||||
// only merge into a report of the SAME org, and an org-less overlay may
|
||||
// only merge into an org-less report. Within the same org the overlay's
|
||||
// base identity must still match — two Team members' reports share the
|
||||
// org id but must not swallow each other's header ingests.
|
||||
const overlayOrg = readMetadataString(metadata, "orgId")?.toLowerCase();
|
||||
const accountId = readMetadataString(metadata, "accountId")?.toLowerCase();
|
||||
const email = readMetadataString(metadata, "email")?.toLowerCase();
|
||||
const projectId = readMetadataString(metadata, "projectId")?.toLowerCase();
|
||||
if (overlayOrg) {
|
||||
const sameOrg: { report: UsageReport; index: number }[] = [];
|
||||
let sawReportOrg = false;
|
||||
for (const candidate of all) {
|
||||
const candidateOrg = readMetadataString((candidate.report.metadata ?? {}) as Record<string, unknown>, "orgId");
|
||||
if (candidateOrg) {
|
||||
sawReportOrg = true;
|
||||
if (candidateOrg.toLowerCase() === overlayOrg) return candidate.index;
|
||||
if (candidateOrg.toLowerCase() === overlayOrg) sameOrg.push(candidate);
|
||||
}
|
||||
}
|
||||
if (sawReportOrg) return -1;
|
||||
if (sawReportOrg) {
|
||||
if (accountId || email || projectId) {
|
||||
for (const candidate of sameOrg) {
|
||||
if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
return sameOrg.length === 1 ? sameOrg[0]!.index : -1;
|
||||
}
|
||||
}
|
||||
const candidates = overlayOrg
|
||||
? all
|
||||
@@ -1050,9 +1084,6 @@ function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport):
|
||||
);
|
||||
if (candidates.length === 0) return -1;
|
||||
if (candidates.length === 1) return candidates[0]!.index;
|
||||
const accountId = readMetadataString(metadata, "accountId")?.toLowerCase();
|
||||
const email = readMetadataString(metadata, "email")?.toLowerCase();
|
||||
const projectId = readMetadataString(metadata, "projectId")?.toLowerCase();
|
||||
for (const candidate of candidates) {
|
||||
if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index;
|
||||
}
|
||||
|
||||
@@ -505,6 +505,112 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("getUsageReport gates same-org siblings on the member's own identity", async () => {
|
||||
// Two Team members share the org id but draw on per-user pools: the
|
||||
// shared org is a gate, not a match, so Bob must never receive Alice's
|
||||
// report just because it is the first (or only) same-org candidate.
|
||||
const brokerClient = new AuthBrokerClient({ url: "http://127.0.0.1:9", token: "unused" });
|
||||
const now = Date.now();
|
||||
const makeMemberCredential = (name: string, orgId?: string) => ({
|
||||
type: "oauth" as const,
|
||||
access: `remote-access-${name}`,
|
||||
refresh: REMOTE_REFRESH_SENTINEL,
|
||||
expires: now + 120_000,
|
||||
...(name === "org-only" ? {} : { accountId: `account-${name}`, email: `${name}@example.com` }),
|
||||
orgId,
|
||||
});
|
||||
const makeMemberReport = (
|
||||
name: string,
|
||||
orgId: string,
|
||||
usedFraction: number,
|
||||
status: "ok" | "exhausted",
|
||||
): UsageReport => ({
|
||||
provider: "anthropic",
|
||||
fetchedAt: now,
|
||||
limits: [
|
||||
{
|
||||
id: "anthropic:5h",
|
||||
label: "Claude 5 Hour",
|
||||
scope: { provider: "anthropic", windowId: "5h" },
|
||||
window: { id: "5h", label: "5 Hour" },
|
||||
amount: { used: usedFraction * 100, limit: 100, usedFraction, unit: "percent" },
|
||||
status,
|
||||
},
|
||||
],
|
||||
metadata: { email: `${name}@example.com`, accountId: `account-${name}`, orgId },
|
||||
});
|
||||
// Bob's report deliberately precedes Alice's so a first-same-org match
|
||||
// would hand his pool to Alice; org-duo holds only Dave's report.
|
||||
vi.spyOn(brokerClient, "fetchUsage").mockResolvedValue({
|
||||
generatedAt: now,
|
||||
reports: [
|
||||
makeMemberReport("bob", "org-team", 0.1, "ok"),
|
||||
makeMemberReport("alice", "org-team", 1, "exhausted"),
|
||||
makeMemberReport("dave", "org-duo", 0.5, "ok"),
|
||||
],
|
||||
});
|
||||
const remoteStore = new RemoteAuthCredentialStore({
|
||||
client: brokerClient,
|
||||
streamSnapshots: false,
|
||||
initialSnapshot: {
|
||||
generation: 1,
|
||||
generatedAt: now,
|
||||
serverNowMs: now,
|
||||
refresher: { enabled: false, intervalMs: 0, skewMs: 0, nextSweepInMs: Number.MAX_SAFE_INTEGER },
|
||||
credentials: [],
|
||||
},
|
||||
});
|
||||
try {
|
||||
// Each member routes to their OWN pool inside the shared org.
|
||||
const aliceReport = await remoteStore.getUsageReport("anthropic", makeMemberCredential("alice", "org-team"));
|
||||
expect(aliceReport?.metadata?.accountId).toBe("account-alice");
|
||||
expect(requireLimit(aliceReport!, "anthropic:5h").status).toBe("exhausted");
|
||||
const bobReport = await remoteStore.getUsageReport("anthropic", makeMemberCredential("bob", "org-team"));
|
||||
expect(bobReport?.metadata?.accountId).toBe("account-bob");
|
||||
expect(requireLimit(bobReport!, "anthropic:5h").status).toBe("ok");
|
||||
|
||||
// Erin's own report is missing: the lone same-org sibling report
|
||||
// (Dave's) must not stand in for hers — "no usage data" is correct.
|
||||
expect(await remoteStore.getUsageReport("anthropic", makeMemberCredential("erin", "org-duo"))).toBeNull();
|
||||
|
||||
// An org-only credential (no base identifiers) still matches on the
|
||||
// org alone, but only when the same-org report is unambiguous.
|
||||
const duoReport = await remoteStore.getUsageReport("anthropic", makeMemberCredential("org-only", "org-duo"));
|
||||
expect(duoReport?.metadata?.accountId).toBe("account-dave");
|
||||
expect(await remoteStore.getUsageReport("anthropic", makeMemberCredential("org-only", "org-team"))).toBeNull();
|
||||
|
||||
// Header-ingest overlays partition per member too: Alice's ingest
|
||||
// must merge into HER aggregate row, not Bob's earlier same-org row.
|
||||
const overlay: UsageReport = {
|
||||
provider: "anthropic",
|
||||
fetchedAt: now,
|
||||
limits: [
|
||||
{
|
||||
id: "anthropic:5h",
|
||||
label: "Claude 5 Hour",
|
||||
scope: { provider: "anthropic", windowId: "5h" },
|
||||
window: { id: "5h", label: "5 Hour" },
|
||||
amount: { used: 90, limit: 100, usedFraction: 0.9, unit: "percent" },
|
||||
status: "ok",
|
||||
},
|
||||
],
|
||||
metadata: { email: "alice@example.com", accountId: "account-alice", orgId: "org-team" },
|
||||
};
|
||||
expect(remoteStore.ingestUsageReport("anthropic", makeMemberCredential("alice", "org-team"), overlay)).toBe(
|
||||
true,
|
||||
);
|
||||
const merged = await remoteStore.fetchUsageReports();
|
||||
const mergedAlice = merged?.find(report => report.metadata?.accountId === "account-alice");
|
||||
const mergedBob = merged?.find(report => report.metadata?.accountId === "account-bob");
|
||||
expect(requireLimit(mergedAlice!, "anthropic:5h").amount.used).toBe(90);
|
||||
expect(requireLimit(mergedBob!, "anthropic:5h").amount.used).toBe(10);
|
||||
const bobAfterIngest = await remoteStore.getUsageReport("anthropic", makeMemberCredential("bob", "org-team"));
|
||||
expect(requireLimit(bobAfterIngest!, "anthropic:5h").amount.used).toBe(10);
|
||||
} finally {
|
||||
remoteStore.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("RemoteAuthCredentialStore reads snapshot blocks and applies upserts before broker acknowledgement", () => {
|
||||
const futureBlock = Date.now() + 60_000;
|
||||
const laterBlock = futureBlock + 60_000;
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
- The status line invalidates its cached usage when the session rotates to a different Anthropic organization (previously the old subscription's quota could linger for the cache TTL), and `omp auth-gateway check` labels each credential with its organization so a failing row says which subscription needs re-login.
|
||||
- `omp usage` "no usage data" attribution is org-decisive whenever either the stored account or a report carries an organization: an org-less legacy credential whose own fetch failed is no longer hidden by an org-attributed sibling report sharing the same email.
|
||||
- Active-account matching for `/usage`, `/logout`, and `omp token --list` now treats a shared organization as a qualifier rather than a match: two Anthropic Team seats in one org (same org id, per-user pools) no longer flag each other's rows or reports as "in use by this session" — the base identity (account/email/project) is still required, with org-only sessions matching on the org alone.
|
||||
- `omp usage` "no usage data" coverage now requires the member's own identity within a shared organization: a sibling Team member's same-org report no longer counts as coverage for an account whose own report is missing, while an org-only account remains covered by any same-org report.
|
||||
|
||||
## [16.4.3] - 2026-07-11
|
||||
|
||||
|
||||
@@ -303,20 +303,33 @@ export function collectUnreportedAccounts(
|
||||
// org-scoped account is covered only by its own org's report, and an
|
||||
// org-less legacy account is never covered by an org-attributed sibling
|
||||
// report — its own fetch failing must surface as "no usage data". The
|
||||
// email/account fallback below applies only when both sides are
|
||||
// org-less.
|
||||
// shared org is a GATE, not a match: two Team members share the org id
|
||||
// while drawing on per-user pools, so coverage also requires the
|
||||
// account's own base identity inside the same-org subset (an org-only
|
||||
// account, with no base identifiers, is covered by any same-org
|
||||
// report). The email/account fallback below applies only when both
|
||||
// sides are org-less.
|
||||
const accountOrg = account.orgId?.toLowerCase();
|
||||
const reportedOrgs = new Set<string>();
|
||||
for (const report of providerReports) {
|
||||
const metaOrg = report.metadata?.orgId;
|
||||
if (typeof metaOrg === "string" && metaOrg) reportedOrgs.add(metaOrg.toLowerCase());
|
||||
}
|
||||
if (accountOrg || reportedOrgs.size > 0) {
|
||||
return !(accountOrg !== undefined && reportedOrgs.has(accountOrg));
|
||||
}
|
||||
const ids = [account.email, account.accountId, account.projectId]
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
.map(value => value.toLowerCase());
|
||||
const sameOrgReports: UsageReport[] = [];
|
||||
let sawReportOrg = false;
|
||||
for (const report of providerReports) {
|
||||
const metaOrg = report.metadata?.orgId;
|
||||
if (typeof metaOrg === "string" && metaOrg) {
|
||||
sawReportOrg = true;
|
||||
if (accountOrg !== undefined && metaOrg.toLowerCase() === accountOrg) sameOrgReports.push(report);
|
||||
}
|
||||
}
|
||||
if (accountOrg || sawReportOrg) {
|
||||
if (!accountOrg || sameOrgReports.length === 0) return true;
|
||||
if (ids.length === 0) return false;
|
||||
return !sameOrgReports.some(report => {
|
||||
const identifiers = reportIdentifiers(report);
|
||||
return ids.some(id => identifiers.has(id));
|
||||
});
|
||||
}
|
||||
if (ids.length === 0) return false;
|
||||
const reported = new Set<string>();
|
||||
let anyIdentified = false;
|
||||
|
||||
@@ -163,6 +163,34 @@ describe("collectUnreportedAccounts", () => {
|
||||
const orglessAccounts: UsageAccountIdentity[] = [{ provider: "anthropic", type: "oauth", email: shared }];
|
||||
expect(collectUnreportedAccounts([orglessReport], orglessAccounts)).toEqual([]);
|
||||
});
|
||||
|
||||
it("gates same-org coverage on the member's own identity", () => {
|
||||
const org = "org-team";
|
||||
const alice: UsageAccountIdentity = {
|
||||
provider: "anthropic",
|
||||
type: "oauth",
|
||||
email: "alice@example.test",
|
||||
accountId: "account-alice",
|
||||
orgId: org,
|
||||
};
|
||||
const bob: UsageAccountIdentity = {
|
||||
provider: "anthropic",
|
||||
type: "oauth",
|
||||
email: "bob@example.test",
|
||||
accountId: "account-bob",
|
||||
orgId: org,
|
||||
};
|
||||
const orgOnly: UsageAccountIdentity = { provider: "anthropic", type: "oauth", orgId: org };
|
||||
const aliceReport = {
|
||||
...makeReport("anthropic", alice.email!, []),
|
||||
metadata: { email: alice.email, accountId: alice.accountId, orgId: org },
|
||||
};
|
||||
// Alice reported, Bob not: the sibling's same-org report must not count
|
||||
// as Bob's coverage — two Team members share the org id but draw on
|
||||
// per-user pools. An org-only account (no base identifiers to gate on)
|
||||
// stays covered by any same-org report.
|
||||
expect(collectUnreportedAccounts([aliceReport], [alice, bob, orgOnly])).toEqual([bob]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUsageBreakdown", () => {
|
||||
|
||||
Reference in New Issue
Block a user