fix(security): scope social account references
This commit is contained in:
1 parent
51d468e4c3
commit
bde35c3518
9 files changed
+317
-44
No files matched your search
@@ -232,7 +232,12 @@ export function buildEntityCrudMethods(
|
||||
},
|
||||
|
||||
deleteSecret(id: string, companyId: string) {
|
||||
getDb().prepare("delete from secrets where id = ? and company_id = ?").run(id, companyId);
|
||||
transaction(() => {
|
||||
getDb()
|
||||
.prepare("update social_accounts set credential_secret_id = null, updated_at = ? where credential_secret_id = ? and company_id = ?")
|
||||
.run(nowIso(), id, companyId);
|
||||
getDb().prepare("delete from secrets where id = ? and company_id = ?").run(id, companyId);
|
||||
});
|
||||
},
|
||||
|
||||
countActiveGoalsForCompany(companyId: string): number {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { nowIso, tableExists } from "./types";
|
||||
|
||||
export const SOCIAL_COMPANY_SCOPE_SCHEMA_VERSION = 59;
|
||||
export const SOCIAL_COMPANY_SCOPE_SCHEMA_NAME = "social_company_scope";
|
||||
|
||||
const TRIGGER_NAMES = [
|
||||
"trg_social_accounts_credential_secret_scope_insert",
|
||||
"trg_social_accounts_credential_secret_scope_update",
|
||||
"trg_browser_actions_social_account_scope_insert",
|
||||
"trg_browser_actions_social_account_scope_update",
|
||||
] as const;
|
||||
|
||||
export function migrateSocialCompanyScopeV59(db: DatabaseSync): void {
|
||||
cleanupInvalidSocialAccountSecretRefs(db);
|
||||
cleanupInvalidBrowserActionRefs(db);
|
||||
installSocialCompanyScopeTriggers(db);
|
||||
db
|
||||
.prepare("insert or replace into schema_migrations (version, name, applied_at) values (?, ?, ?)")
|
||||
.run(SOCIAL_COMPANY_SCOPE_SCHEMA_VERSION, SOCIAL_COMPANY_SCOPE_SCHEMA_NAME, nowIso());
|
||||
assertSocialCompanyScopeV59(db);
|
||||
}
|
||||
|
||||
export function assertSocialCompanyScopeV59(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "social_accounts")) {
|
||||
throw new Error("Schema v59 drift: social_accounts table is missing.");
|
||||
}
|
||||
if (!tableExists(db, "secrets")) {
|
||||
throw new Error("Schema v59 drift: secrets table is missing.");
|
||||
}
|
||||
if (!tableExists(db, "browser_actions")) {
|
||||
throw new Error("Schema v59 drift: browser_actions table is missing.");
|
||||
}
|
||||
const invalid = db
|
||||
.prepare(
|
||||
`select count(*) as count
|
||||
from social_accounts sa
|
||||
where sa.credential_secret_id is not null
|
||||
and not exists (
|
||||
select 1
|
||||
from secrets s
|
||||
where s.id = sa.credential_secret_id
|
||||
and s.company_id = sa.company_id
|
||||
)`,
|
||||
)
|
||||
.get() as { count: number } | undefined;
|
||||
if ((invalid?.count ?? 0) > 0) {
|
||||
throw new Error("Schema v59 drift: social account credential secrets must belong to the same company.");
|
||||
}
|
||||
const invalidActions = db
|
||||
.prepare(
|
||||
`select count(*) as count
|
||||
from browser_actions ba
|
||||
where not exists (
|
||||
select 1
|
||||
from social_accounts sa
|
||||
where sa.id = ba.social_account_id
|
||||
and sa.company_id = ba.company_id
|
||||
)`,
|
||||
)
|
||||
.get() as { count: number } | undefined;
|
||||
if ((invalidActions?.count ?? 0) > 0) {
|
||||
throw new Error("Schema v59 drift: browser actions must reference social accounts in the same company.");
|
||||
}
|
||||
for (const triggerName of TRIGGER_NAMES) {
|
||||
if (!triggerExists(db, triggerName)) {
|
||||
throw new Error(`Schema v59 drift: ${triggerName} trigger is missing.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupInvalidSocialAccountSecretRefs(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "social_accounts") || !tableExists(db, "secrets")) return;
|
||||
db
|
||||
.prepare(
|
||||
`update social_accounts
|
||||
set credential_secret_id = null,
|
||||
updated_at = ?
|
||||
where credential_secret_id is not null
|
||||
and not exists (
|
||||
select 1
|
||||
from secrets s
|
||||
where s.id = social_accounts.credential_secret_id
|
||||
and s.company_id = social_accounts.company_id
|
||||
)`,
|
||||
)
|
||||
.run(nowIso());
|
||||
}
|
||||
|
||||
function cleanupInvalidBrowserActionRefs(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "browser_actions") || !tableExists(db, "social_accounts")) return;
|
||||
db
|
||||
.prepare(
|
||||
`delete from browser_actions
|
||||
where not exists (
|
||||
select 1
|
||||
from social_accounts sa
|
||||
where sa.id = browser_actions.social_account_id
|
||||
and sa.company_id = browser_actions.company_id
|
||||
)`,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
function installSocialCompanyScopeTriggers(db: DatabaseSync): void {
|
||||
for (const triggerName of TRIGGER_NAMES) {
|
||||
db.exec(`drop trigger if exists ${triggerName}`);
|
||||
}
|
||||
db.exec(`
|
||||
create trigger trg_social_accounts_credential_secret_scope_insert
|
||||
before insert on social_accounts
|
||||
when new.credential_secret_id is not null
|
||||
and not exists (
|
||||
select 1 from secrets
|
||||
where id = new.credential_secret_id
|
||||
and company_id = new.company_id
|
||||
)
|
||||
begin
|
||||
select raise(abort, 'social account credential secret must belong to the same company');
|
||||
end;
|
||||
|
||||
create trigger trg_social_accounts_credential_secret_scope_update
|
||||
before update of credential_secret_id, company_id on social_accounts
|
||||
when new.credential_secret_id is not null
|
||||
and not exists (
|
||||
select 1 from secrets
|
||||
where id = new.credential_secret_id
|
||||
and company_id = new.company_id
|
||||
)
|
||||
begin
|
||||
select raise(abort, 'social account credential secret must belong to the same company');
|
||||
end;
|
||||
|
||||
create trigger trg_browser_actions_social_account_scope_insert
|
||||
before insert on browser_actions
|
||||
when not exists (
|
||||
select 1 from social_accounts
|
||||
where id = new.social_account_id
|
||||
and company_id = new.company_id
|
||||
)
|
||||
begin
|
||||
select raise(abort, 'browser action social account must belong to the same company');
|
||||
end;
|
||||
|
||||
create trigger trg_browser_actions_social_account_scope_update
|
||||
before update of social_account_id, company_id on browser_actions
|
||||
when not exists (
|
||||
select 1 from social_accounts
|
||||
where id = new.social_account_id
|
||||
and company_id = new.company_id
|
||||
)
|
||||
begin
|
||||
select raise(abort, 'browser action social account must belong to the same company');
|
||||
end;
|
||||
`);
|
||||
}
|
||||
|
||||
function triggerExists(db: DatabaseSync, name: string): boolean {
|
||||
const row = db
|
||||
.prepare("select name from sqlite_master where type = 'trigger' and name = ? limit 1")
|
||||
.get(name) as { name: string } | undefined;
|
||||
return Boolean(row);
|
||||
}
|
||||
@@ -19,12 +19,17 @@ import {
|
||||
assertApprovalPoliciesV58,
|
||||
migrateApprovalPoliciesV58,
|
||||
} from "./migrations/v58-approval-policies";
|
||||
import {
|
||||
SOCIAL_COMPANY_SCOPE_SCHEMA_VERSION,
|
||||
assertSocialCompanyScopeV59,
|
||||
migrateSocialCompanyScopeV59,
|
||||
} from "./migrations/v59-social-company-scope";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
} from "./migrations/types";
|
||||
|
||||
const LATEST_SCHEMA_VERSION = APPROVAL_POLICY_SCHEMA_VERSION;
|
||||
const LATEST_SCHEMA_VERSION = SOCIAL_COMPANY_SCOPE_SCHEMA_VERSION;
|
||||
|
||||
export function initializeDatabase(
|
||||
db: DatabaseSync,
|
||||
@@ -44,6 +49,17 @@ export function initializeDatabase(
|
||||
seedSettings(db);
|
||||
assertTaskLeasesV57(db);
|
||||
assertApprovalPoliciesV58(db);
|
||||
assertSocialCompanyScopeV59(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentVersion === APPROVAL_POLICY_SCHEMA_VERSION) {
|
||||
tx(() => {
|
||||
seedSettings(db);
|
||||
assertTaskLeasesV57(db);
|
||||
assertApprovalPoliciesV58(db);
|
||||
migrateSocialCompanyScopeV59(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -53,6 +69,7 @@ export function initializeDatabase(
|
||||
seedSettings(db);
|
||||
assertTaskLeasesV57(db);
|
||||
migrateApprovalPoliciesV58(db);
|
||||
migrateSocialCompanyScopeV59(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -65,6 +82,7 @@ export function initializeDatabase(
|
||||
assertBaselineSchema(db);
|
||||
migrateTaskLeasesV57(db);
|
||||
migrateApprovalPoliciesV58(db);
|
||||
migrateSocialCompanyScopeV59(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -78,6 +96,7 @@ export function initializeDatabase(
|
||||
assertBaselineSchema(db);
|
||||
migrateTaskLeasesV57(db);
|
||||
migrateApprovalPoliciesV58(db);
|
||||
migrateSocialCompanyScopeV59(db);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -89,6 +108,7 @@ export function initializeDatabase(
|
||||
assertBaselineSchema(db);
|
||||
migrateTaskLeasesV57(db);
|
||||
migrateApprovalPoliciesV58(db);
|
||||
migrateSocialCompanyScopeV59(db);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+67
-23
@@ -2,7 +2,17 @@ import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type { BrowserActionRecord, SocialAccountRecord } from "@shared/types";
|
||||
import type { ReadAll, SqliteRow, Transaction } from "./helpers";
|
||||
import { ensureId, nowIso, toBoolean } from "./helpers";
|
||||
import { ensureId, normalizeOptionalId, nowIso, toBoolean } from "./helpers";
|
||||
|
||||
export class SocialAccountScopeError extends Error {
|
||||
constructor(
|
||||
public readonly reference: "social_account" | "credential_secret",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SocialAccountScopeError";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSocialMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
@@ -22,33 +32,61 @@ export function buildSocialMethods(
|
||||
requireApproval?: boolean;
|
||||
metadataJson?: string;
|
||||
}): string {
|
||||
const id = ensureId(input.id);
|
||||
const now = nowIso();
|
||||
getDb().prepare(`INSERT INTO social_accounts (id, company_id, platform, account_name, display_name, profile_url, credential_secret_id, status, require_approval, metadata_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
platform = excluded.platform,
|
||||
account_name = excluded.account_name,
|
||||
display_name = excluded.display_name,
|
||||
profile_url = excluded.profile_url,
|
||||
credential_secret_id = excluded.credential_secret_id,
|
||||
status = excluded.status,
|
||||
require_approval = excluded.require_approval,
|
||||
metadata_json = excluded.metadata_json,
|
||||
updated_at = excluded.updated_at`).run(
|
||||
id, input.companyId, input.platform, input.accountName,
|
||||
input.displayName ?? "", input.profileUrl ?? "",
|
||||
input.credentialSecretId ?? null, input.status ?? "login_required",
|
||||
input.requireApproval !== false ? 1 : 0,
|
||||
input.metadataJson ?? "{}", now, now,
|
||||
);
|
||||
return id;
|
||||
return transaction(() => {
|
||||
const id = ensureId(input.id);
|
||||
const now = nowIso();
|
||||
const credentialSecretId = normalizeOptionalId(input.credentialSecretId);
|
||||
const existing = getDb()
|
||||
.prepare("SELECT id, company_id AS companyId FROM social_accounts WHERE id = ? LIMIT 1")
|
||||
.get(id) as { id: string; companyId: string } | undefined;
|
||||
if (existing && existing.companyId !== input.companyId) {
|
||||
throw new SocialAccountScopeError("social_account", "Social account does not belong to the current company");
|
||||
}
|
||||
if (credentialSecretId) {
|
||||
const secret = getDb()
|
||||
.prepare("SELECT id FROM secrets WHERE id = ? AND company_id = ? LIMIT 1")
|
||||
.get(credentialSecretId, input.companyId) as { id: string } | undefined;
|
||||
if (!secret) {
|
||||
throw new SocialAccountScopeError("credential_secret", "Credential secret does not belong to the current company");
|
||||
}
|
||||
}
|
||||
if (existing) {
|
||||
getDb().prepare(`UPDATE social_accounts
|
||||
SET platform = ?,
|
||||
account_name = ?,
|
||||
display_name = ?,
|
||||
profile_url = ?,
|
||||
credential_secret_id = ?,
|
||||
status = ?,
|
||||
require_approval = ?,
|
||||
metadata_json = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND company_id = ?`).run(
|
||||
input.platform, input.accountName,
|
||||
input.displayName ?? "", input.profileUrl ?? "",
|
||||
credentialSecretId, input.status ?? "login_required",
|
||||
input.requireApproval !== false ? 1 : 0,
|
||||
input.metadataJson ?? "{}", now,
|
||||
id, input.companyId,
|
||||
);
|
||||
} else {
|
||||
getDb().prepare(`INSERT INTO social_accounts (id, company_id, platform, account_name, display_name, profile_url, credential_secret_id, status, require_approval, metadata_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
id, input.companyId, input.platform, input.accountName,
|
||||
input.displayName ?? "", input.profileUrl ?? "",
|
||||
credentialSecretId, input.status ?? "login_required",
|
||||
input.requireApproval !== false ? 1 : 0,
|
||||
input.metadataJson ?? "{}", now, now,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
});
|
||||
},
|
||||
|
||||
deleteSocialAccount(id: string, companyId: string) {
|
||||
transaction(() => {
|
||||
getDb().prepare("DELETE FROM social_accounts WHERE id = ? AND company_id = ?").run(id, companyId);
|
||||
getDb().prepare("DELETE FROM browser_actions WHERE social_account_id = ? AND company_id = ?").run(id, companyId);
|
||||
getDb().prepare("DELETE FROM social_accounts WHERE id = ? AND company_id = ?").run(id, companyId);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -91,6 +129,12 @@ export function buildSocialMethods(
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
const now = nowIso();
|
||||
const socialAccount = getDb()
|
||||
.prepare("SELECT id FROM social_accounts WHERE id = ? AND company_id = ? LIMIT 1")
|
||||
.get(input.socialAccountId, input.companyId) as { id: string } | undefined;
|
||||
if (!socialAccount) {
|
||||
throw new SocialAccountScopeError("social_account", "Browser action social account does not belong to the current company");
|
||||
}
|
||||
getDb().prepare(`INSERT INTO browser_actions (id, company_id, social_account_id, agent_id, task_id, action_type, payload_json, status, approval_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
id, input.companyId, input.socialAccountId, input.agentId,
|
||||
|
||||
Reference in new issue
Block a user