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
1 change: 1 addition & 0 deletions apps/api/scripts/lint-meta/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi
| `docs-no-retired-credentials` | source-text | no | Documentation prose must not reference retired default credentials. |
| `external-client-timeout` | source-text | no | SDK clients (Stripe/OpenAI/Anthropic) need a timeout option; email transports (Resend/SendGrid/nodemailer) must be bounded; fetch() in src needs an AbortSignal. |
| `no-raw-role-literal` | source-text | no | Use ROLE.* from acl.constants.ts instead of raw owner/admin/member/viewer string literals. |
| `audit-log-read-account-scoped` | source-text | no | Queries filtering auditLog by userId must also reference auditLog.targetAccountId — userId-only reads bleed a multi-account user's events across tenant boundaries. |
| `routes-require-test-sibling` | testing | no | Route modules must ship with a matching HTTP-level test under tests/api/. |
| `logic-files-require-test-sibling` | testing | no | Logic modules must ship with a matching tests/**/*.test.ts sibling. |
| `lint-meta-rules-self-covered` | testing | no | Every lint-meta rule module must re-export its check function from cli.ts and carry a describe() test block — the guardrails must themselves be guarded. |
Expand Down
2 changes: 2 additions & 0 deletions apps/api/scripts/lint-meta/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
checkSecurityScannerVersionParity,
} from "./rules/ci/security-scanner-version-parity";
import { checkTofuBootstrapHardening } from "./rules/ci/tofu-bootstrap-hardening";
import { checkAuditLogReadAccountScoped } from "./rules/source-text/audit-log-read-account-scoped";
import { checkCanonicalHelpersSingleHome } from "./rules/source-text/canonical-helpers-single-home";
import { checkDocsNoRetiredCredentials } from "./rules/source-text/docs-no-retired-credentials";
import { checkExternalClientTimeouts } from "./rules/source-text/external-client-timeout";
Expand Down Expand Up @@ -113,6 +114,7 @@ export { collectSourceFiles, findWorkflows } from "./context";
export { parseDotenvKeys } from "./parsers/dotenv";
export { parseTypeboxEnvSchemaKeys as parseEnvSchemaKeys } from "./parsers/typebox-env-schema";
export {
checkAuditLogReadAccountScoped,
checkCanonicalHelpersSingleHome,
checkDependencyPairs,
checkDocsNoRetiredCredentials,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/scripts/lint-meta/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { eslintOverridePathsExistRule } from "./rules/config/eslint-override-pat
import { tsconfigIncludePathsExistRule } from "./rules/config/tsconfig-include-paths-exist";
import { envCascadeDriftRule } from "./rules/env/env-cascade-drift";
import { noDirectProcessEnvRule } from "./rules/env/no-direct-process-env";
import { auditLogReadAccountScopedRule } from "./rules/source-text/audit-log-read-account-scoped";
import { canonicalHelpersSingleHomeRule } from "./rules/source-text/canonical-helpers-single-home";
import { docsNoRetiredCredentialsRule } from "./rules/source-text/docs-no-retired-credentials";
import { externalClientTimeoutRule } from "./rules/source-text/external-client-timeout";
Expand Down Expand Up @@ -65,6 +66,7 @@ export const META_RULES: readonly IMetaRule[] = [
docsNoRetiredCredentialsRule,
externalClientTimeoutRule,
noRawRoleLiteralsRule,
auditLogReadAccountScopedRule,
routesRequireTestSiblingRule,
logicFilesRequireTestSiblingRule,
lintMetaRulesSelfCoveredRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { readFileSync } from "node:fs";

import type { IMetaRule, IViolation } from "../../types";

/*
* Audit-log reads are tenant data. A query that filters auditLog by
* userId alone returns the user's events from EVERY account they belong
* to, bleeding account-level activity (billing changes, invitations,
* feature overrides) across tenant boundaries — the dashboard feed
* shipped exactly this defect. The schema carries an indexed
* targetAccountId for scoping, so any file that filters on
* eq(auditLog.userId, …) must also reference auditLog.targetAccountId
* in its query. Write paths (inserts) and join conditions like
* eq(users.id, auditLog.userId) don't match the filter pattern, so the
* check stays precise without an allowlist.
*/
const RULE = "audit-log-read-account-scoped";
const USER_FILTER_RE = /eq\(auditLog\.userId\b/u;
const ACCOUNT_SCOPE_TOKEN = "auditLog.targetAccountId";

export function checkAuditLogReadAccountScoped(
root: string,
sourceFiles: readonly string[]
): IViolation[] {
const violations: IViolation[] = [];

for (const file of sourceFiles) {
const relative = file.startsWith(root) ? file.slice(root.length + 1) : file;

if (!relative.startsWith("src/") || relative.endsWith(".test.ts")) {
continue;
}

const content = readFileSync(file, "utf8");

if (
!USER_FILTER_RE.test(content) ||
content.includes(ACCOUNT_SCOPE_TOKEN)
) {
continue;
}

const lines = content.split("\n");

for (let index = 0; index < lines.length; index++) {
if (!USER_FILTER_RE.test(lines[index] ?? "")) {
continue;
}

violations.push({
file,
rule: RULE,
message: `Line ${String(index + 1)}: auditLog query filters by userId without targetAccountId scoping — a multi-account user sees events from every account. Add an auditLog.targetAccountId clause (or isNull for user-level events).`,
});
}
}

return violations;
}

/** auditLog reads filtered by userId must also scope on targetAccountId. */
export const auditLogReadAccountScopedRule: IMetaRule = {
id: "audit-log-read-account-scoped",
category: "source-text",
description:
"Queries filtering auditLog by userId must also reference auditLog.targetAccountId — userId-only reads bleed a multi-account user's events across tenant boundaries.",
run({ root, sourceFiles }) {
return checkAuditLogReadAccountScoped(root, sourceFiles);
},
};
24 changes: 15 additions & 9 deletions apps/api/src/api/dashboard/dashboard.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,25 @@ const dashboardRoutes = requireAuth()
.onError(({ code, error, set }) =>
errorHandler({ code: String(code), error, set })
)
.get("/summary", async ({ user }) => dashboardService.getSummary(user.id), {
response: DashboardSummarySchema,
detail: {
tags: ["Dashboard"],
summary: "Dashboard summary stats (per user)",
security: [{ cookieAuth: [] }],
},
})
.get(
"/summary",
async ({ user, accountId }) =>
dashboardService.getSummary(user.id, accountId),
{
response: DashboardSummarySchema,
detail: {
tags: ["Dashboard"],
summary: "Dashboard summary stats (per user)",
security: [{ cookieAuth: [] }],
},
}
)
.get(
"/activity",
async ({ user, query }) =>
async ({ user, accountId, query }) =>
dashboardService.getActivity(
user.id,
accountId,
parseDashboardLimit(query.limit),
query.cursor
),
Expand Down
42 changes: 36 additions & 6 deletions apps/api/src/api/dashboard/dashboard.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, count, desc, eq, lt, or, type SQL } from "drizzle-orm";
import { and, count, desc, eq, isNull, lt, or, type SQL } from "drizzle-orm";

import { db } from "../../clients/postgres";
import { auditLog } from "../../clients/postgres/schema";
Expand All @@ -7,11 +7,37 @@ import type { IActivityPage, IDashboardSummary } from "./dashboard.types";
import { formatActivityTitle } from "./dashboard.utils";

export class DashboardService {
async getSummary(userId: string): Promise<IDashboardSummary> {
/*
* Tenant scope for the feed: the user's own events in the current
* account, plus their user-level events (null targetAccountId — logins,
* profile changes). Events targeting OTHER accounts the user belongs to
* must never leak into this account's dashboard.
*/
private scopedUserFilters(userId: string, accountId: string): SQL[] {
const filters: SQL[] = [eq(auditLog.userId, userId)];

const accountScope = or(
eq(auditLog.targetAccountId, accountId),
isNull(auditLog.targetAccountId)
);

if (accountScope !== undefined) {
filters.push(accountScope);
}

return filters;
}

async getSummary(
userId: string,
accountId: string
): Promise<IDashboardSummary> {
const scope = and(...this.scopedUserFilters(userId, accountId));

const [eventCountRow] = await db
.select({ value: count() })
.from(auditLog)
.where(eq(auditLog.userId, userId));
.where(scope);

const recent = await db
.select({
Expand All @@ -21,7 +47,7 @@ export class DashboardService {
createdAt: auditLog.createdAt,
})
.from(auditLog)
.where(eq(auditLog.userId, userId))
.where(scope)
.orderBy(desc(auditLog.createdAt))
.limit(5);

Expand All @@ -37,15 +63,19 @@ export class DashboardService {

async getActivity(
userId: string,
accountId: string,
limit: number,
cursor?: string
): Promise<IActivityPage> {
const filters: SQL[] = [eq(auditLog.userId, userId)];
const filters: SQL[] = this.scopedUserFilters(userId, accountId);

if (cursor !== undefined && cursor !== "") {
const cursorId = cursor.replace(/^cursor:/, "");
const cursorRow = await db.query.auditLog.findFirst({
where: and(eq(auditLog.id, cursorId), eq(auditLog.userId, userId)),
where: and(
eq(auditLog.id, cursorId),
...this.scopedUserFilters(userId, accountId)
),
});

if (cursorRow === undefined) {
Expand Down
Loading
Loading