Add browser platform governance records
This commit is contained in:
1 parent
18e0c6338e
commit
eab282d8b1
12 files changed
+536
-78
No files matched your search
@@ -0,0 +1,127 @@
|
||||
export const browserPlatformGovernanceSchema = `
|
||||
create table if not exists browser_session_containers (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
social_account_id text not null references social_accounts(id) on delete cascade,
|
||||
platform text not null,
|
||||
partition_key text not null,
|
||||
host_allowlist_json text not null,
|
||||
permission_policy text not null,
|
||||
sandbox_enabled integer not null,
|
||||
created_at text not null,
|
||||
last_used_at text,
|
||||
unique(social_account_id)
|
||||
);
|
||||
|
||||
create table if not exists browser_action_policies (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
social_account_id text not null references social_accounts(id) on delete cascade,
|
||||
action_type text not null,
|
||||
content_category text not null,
|
||||
risk_level text not null,
|
||||
approval_required integer not null,
|
||||
approval_kind text,
|
||||
created_at text not null,
|
||||
unique(social_account_id, action_type, content_category)
|
||||
);
|
||||
|
||||
create table if not exists social_approval_routes (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
social_account_id text not null references social_accounts(id) on delete cascade,
|
||||
action_type text not null,
|
||||
risk_level text not null,
|
||||
approval_kind text not null,
|
||||
route_to_type text not null,
|
||||
route_to_id text,
|
||||
created_at text not null,
|
||||
unique(social_account_id, action_type, risk_level)
|
||||
);
|
||||
|
||||
insert into browser_session_containers(
|
||||
id, org_id, social_account_id, platform, partition_key, host_allowlist_json,
|
||||
permission_policy, sandbox_enabled, created_at, last_used_at
|
||||
)
|
||||
select
|
||||
'bsc_' || lower(hex(randomblob(16))),
|
||||
org_id,
|
||||
id,
|
||||
platform,
|
||||
'persist:agentcompany-social-' || id,
|
||||
case platform
|
||||
when 'x' then '["x.com","twitter.com"]'
|
||||
when 'linkedin' then '["linkedin.com","www.linkedin.com"]'
|
||||
when 'youtube' then '["accounts.google.com","youtube.com","www.youtube.com"]'
|
||||
when 'instagram' then '["instagram.com","www.instagram.com"]'
|
||||
else '[]'
|
||||
end,
|
||||
'deny_all',
|
||||
1,
|
||||
datetime('now'),
|
||||
null
|
||||
from social_accounts
|
||||
where not exists (
|
||||
select 1 from browser_session_containers
|
||||
where browser_session_containers.social_account_id = social_accounts.id
|
||||
);
|
||||
|
||||
insert into browser_action_policies(
|
||||
id, org_id, social_account_id, action_type, content_category, risk_level,
|
||||
approval_required, approval_kind, created_at
|
||||
)
|
||||
select
|
||||
'bap_' || lower(hex(randomblob(16))),
|
||||
social_accounts.org_id,
|
||||
social_accounts.id,
|
||||
policy.action_type,
|
||||
policy.content_category,
|
||||
policy.risk_level,
|
||||
policy.approval_required,
|
||||
policy.approval_kind,
|
||||
datetime('now')
|
||||
from social_accounts
|
||||
join (
|
||||
select 'login' action_type, 'auth' content_category, 'low' risk_level, 0 approval_required, null approval_kind
|
||||
union all select 'draft', 'content', 'low', 0, null
|
||||
union all select 'approve', 'content', 'medium', 1, 'social_publish'
|
||||
) policy
|
||||
where not exists (
|
||||
select 1 from browser_action_policies
|
||||
where browser_action_policies.social_account_id = social_accounts.id
|
||||
and browser_action_policies.action_type = policy.action_type
|
||||
and browser_action_policies.content_category = policy.content_category
|
||||
);
|
||||
|
||||
insert into social_approval_routes(
|
||||
id, org_id, social_account_id, action_type, risk_level, approval_kind,
|
||||
route_to_type, route_to_id, created_at
|
||||
)
|
||||
select
|
||||
'sar_' || lower(hex(randomblob(16))),
|
||||
org_id,
|
||||
id,
|
||||
'approve',
|
||||
'medium',
|
||||
'social_publish',
|
||||
'authorized_employee',
|
||||
authorized_employee_id,
|
||||
datetime('now')
|
||||
from social_accounts
|
||||
where not exists (
|
||||
select 1 from social_approval_routes
|
||||
where social_approval_routes.social_account_id = social_accounts.id
|
||||
and social_approval_routes.action_type = 'approve'
|
||||
and social_approval_routes.risk_level = 'medium'
|
||||
);
|
||||
|
||||
create index if not exists idx_browser_session_containers_org
|
||||
on browser_session_containers(org_id, social_account_id);
|
||||
create index if not exists idx_browser_action_policies_org_account
|
||||
on browser_action_policies(org_id, social_account_id, action_type);
|
||||
create index if not exists idx_social_approval_routes_org_account
|
||||
on social_approval_routes(org_id, social_account_id, action_type);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (26, 'browser_platform_governance', datetime('now'));
|
||||
`;
|
||||
@@ -24,6 +24,7 @@ import { providerRateLimitObservationSchema } from "./migrations/022-provider-ra
|
||||
import { providerRequestRuntimeSchema } from "./migrations/023-provider-request-runtime";
|
||||
import { taskRunProviderRequestSchema } from "./migrations/024-task-run-provider-request";
|
||||
import { chatMessageDeliverySchema } from "./migrations/025-chat-message-delivery";
|
||||
import { browserPlatformGovernanceSchema } from "./migrations/026-browser-platform-governance";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -57,6 +58,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 23, name: "provider_request_runtime", sql: providerRequestRuntimeSchema },
|
||||
{ version: 24, name: "task_run_provider_request", sql: taskRunProviderRequestSchema },
|
||||
{ version: 25, name: "chat_message_delivery", sql: chatMessageDeliverySchema },
|
||||
{ version: 26, name: "browser_platform_governance", sql: browserPlatformGovernanceSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -64,6 +64,9 @@ const LEGACY_TABLES = [
|
||||
"file_access_grants",
|
||||
"social_accounts",
|
||||
"social_account_grants",
|
||||
"browser_session_containers",
|
||||
"browser_action_policies",
|
||||
"social_approval_routes",
|
||||
"browser_actions",
|
||||
"scheduled_posts",
|
||||
"policy_rules",
|
||||
@@ -214,6 +217,13 @@ const RELATION_CHECKS: RelationCheck[] = [
|
||||
{ table: "social_account_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "social_account_grants", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
|
||||
{ table: "social_account_grants", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" },
|
||||
{ table: "browser_session_containers", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "browser_session_containers", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
|
||||
{ table: "browser_action_policies", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "browser_action_policies", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
|
||||
{ table: "social_approval_routes", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "social_approval_routes", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
|
||||
{ table: "social_approval_routes", column: "route_to_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_route_to_id" },
|
||||
{ table: "browser_actions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "browser_actions", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
|
||||
{ table: "browser_actions", column: "evidence_file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_evidence_file_id" },
|
||||
|
||||
@@ -262,7 +262,7 @@ export class SdlcService {
|
||||
{ key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` },
|
||||
{ key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry },
|
||||
{ key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady },
|
||||
{ key: "schema_version", passed: schemaVersion >= 25, value: schemaVersion },
|
||||
{ key: "schema_version", passed: schemaVersion >= 26, value: schemaVersion },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,11 @@ export class SnapshotService {
|
||||
skillRuntimeSnapshots: list(this.db, "skill_runtime_snapshots", "created_at desc"),
|
||||
socialAccounts: list(this.db, "social_accounts", "created_at asc"),
|
||||
socialAccountGrants: list(this.db, "social_account_grants", "created_at asc"),
|
||||
browserSessionContainers: list<AppSnapshot["browserSessionContainers"][number]>(this.db, "browser_session_containers", "created_at asc")
|
||||
.map((container) => ({ ...container, sandboxEnabled: toBool(container.sandboxEnabled) })),
|
||||
browserActionPolicies: list<AppSnapshot["browserActionPolicies"][number]>(this.db, "browser_action_policies", "created_at asc")
|
||||
.map((policy) => ({ ...policy, approvalRequired: toBool(policy.approvalRequired) })),
|
||||
socialApprovalRoutes: list(this.db, "social_approval_routes", "created_at asc"),
|
||||
browserActions: list(this.db, "browser_actions", "created_at desc"),
|
||||
policyRules: list(this.db, "policy_rules", "created_at asc"),
|
||||
migrationReports: list(this.db, "migration_reports", "created_at desc"),
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { SOCIAL_PLATFORM, type RiskLevel, type SocialPlatform } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
|
||||
export const SOCIAL_BROWSER_ACTION = {
|
||||
LOGIN: "login",
|
||||
DRAFT: "draft",
|
||||
APPROVE: "approve",
|
||||
} as const;
|
||||
|
||||
export type SocialBrowserAction = typeof SOCIAL_BROWSER_ACTION[keyof typeof SOCIAL_BROWSER_ACTION];
|
||||
|
||||
const PLATFORM_HOSTS: Record<SocialPlatform, string[]> = {
|
||||
[SOCIAL_PLATFORM.X]: ["x.com", "twitter.com"],
|
||||
[SOCIAL_PLATFORM.LINKEDIN]: ["linkedin.com", "www.linkedin.com"],
|
||||
[SOCIAL_PLATFORM.YOUTUBE]: ["accounts.google.com", "youtube.com", "www.youtube.com"],
|
||||
[SOCIAL_PLATFORM.INSTAGRAM]: ["instagram.com", "www.instagram.com"],
|
||||
};
|
||||
|
||||
const ACTION_POLICIES: ReadonlyArray<{
|
||||
actionType: SocialBrowserAction;
|
||||
contentCategory: string;
|
||||
riskLevel: RiskLevel;
|
||||
approvalRequired: boolean;
|
||||
approvalKind: string | null;
|
||||
}> = [
|
||||
{ actionType: SOCIAL_BROWSER_ACTION.LOGIN, contentCategory: "auth", riskLevel: "low", approvalRequired: false, approvalKind: null },
|
||||
{ actionType: SOCIAL_BROWSER_ACTION.DRAFT, contentCategory: "content", riskLevel: "low", approvalRequired: false, approvalKind: null },
|
||||
{ actionType: SOCIAL_BROWSER_ACTION.APPROVE, contentCategory: "content", riskLevel: "medium", approvalRequired: true, approvalKind: "social_publish" },
|
||||
];
|
||||
|
||||
export interface BrowserSessionContainerRow {
|
||||
id: string;
|
||||
partition_key: string;
|
||||
host_allowlist_json: string;
|
||||
permission_policy: string;
|
||||
sandbox_enabled: number;
|
||||
}
|
||||
|
||||
export interface BrowserActionPolicyRow {
|
||||
id: string;
|
||||
action_type: SocialBrowserAction;
|
||||
risk_level: RiskLevel;
|
||||
approval_required: number;
|
||||
approval_kind: string | null;
|
||||
}
|
||||
|
||||
export interface SocialApprovalRouteRow {
|
||||
id: string;
|
||||
approval_kind: string;
|
||||
route_to_type: string;
|
||||
route_to_id: string | null;
|
||||
}
|
||||
|
||||
export class SocialBrowserPolicyService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
seedAccountControls(input: {
|
||||
orgId: string;
|
||||
socialAccountId: string;
|
||||
platform: SocialPlatform;
|
||||
authorizedEmployeeId: string | null;
|
||||
createdAt: string;
|
||||
}): void {
|
||||
this.db.prepare(`
|
||||
insert or ignore into browser_session_containers(
|
||||
id, org_id, social_account_id, platform, partition_key, host_allowlist_json,
|
||||
permission_policy, sandbox_enabled, created_at, last_used_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, null)
|
||||
`).run(
|
||||
createId("bsc"),
|
||||
input.orgId,
|
||||
input.socialAccountId,
|
||||
input.platform,
|
||||
`persist:agentcompany-social-${input.socialAccountId}`,
|
||||
JSON.stringify(PLATFORM_HOSTS[input.platform]),
|
||||
"deny_all",
|
||||
1,
|
||||
input.createdAt,
|
||||
);
|
||||
for (const policy of ACTION_POLICIES) {
|
||||
this.db.prepare(`
|
||||
insert or ignore into browser_action_policies(
|
||||
id, org_id, social_account_id, action_type, content_category, risk_level,
|
||||
approval_required, approval_kind, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("bap"),
|
||||
input.orgId,
|
||||
input.socialAccountId,
|
||||
policy.actionType,
|
||||
policy.contentCategory,
|
||||
policy.riskLevel,
|
||||
policy.approvalRequired ? 1 : 0,
|
||||
policy.approvalKind,
|
||||
input.createdAt,
|
||||
);
|
||||
}
|
||||
this.db.prepare(`
|
||||
insert or ignore into social_approval_routes(
|
||||
id, org_id, social_account_id, action_type, risk_level, approval_kind,
|
||||
route_to_type, route_to_id, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("sar"),
|
||||
input.orgId,
|
||||
input.socialAccountId,
|
||||
SOCIAL_BROWSER_ACTION.APPROVE,
|
||||
"medium",
|
||||
"social_publish",
|
||||
"authorized_employee",
|
||||
input.authorizedEmployeeId,
|
||||
input.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
requireSessionContainer(orgId: string, socialAccountId: string): BrowserSessionContainerRow {
|
||||
const row = this.db.prepare(`
|
||||
select id, partition_key, host_allowlist_json, permission_policy, sandbox_enabled
|
||||
from browser_session_containers
|
||||
where org_id = ? and social_account_id = ?
|
||||
`).get(orgId, socialAccountId) as BrowserSessionContainerRow | undefined;
|
||||
if (!row) throw new Error("Browser session container is required.");
|
||||
this.parseHostAllowlist(row.host_allowlist_json);
|
||||
if (row.permission_policy !== "deny_all") throw new Error("Browser permission policy is invalid.");
|
||||
if (row.sandbox_enabled !== 1) throw new Error("Browser sandbox policy is required.");
|
||||
return row;
|
||||
}
|
||||
|
||||
allowedHosts(container: BrowserSessionContainerRow): string[] {
|
||||
return this.parseHostAllowlist(container.host_allowlist_json);
|
||||
}
|
||||
|
||||
markSessionUsed(containerId: string, usedAt: string): void {
|
||||
this.db.prepare("update browser_session_containers set last_used_at = ? where id = ?").run(usedAt, containerId);
|
||||
}
|
||||
|
||||
requireActionPolicy(orgId: string, socialAccountId: string, actionType: SocialBrowserAction): BrowserActionPolicyRow {
|
||||
const row = this.db.prepare(`
|
||||
select id, action_type, risk_level, approval_required, approval_kind
|
||||
from browser_action_policies
|
||||
where org_id = ? and social_account_id = ? and action_type = ?
|
||||
`).get(orgId, socialAccountId, actionType) as BrowserActionPolicyRow | undefined;
|
||||
if (!row) throw new Error("Browser action policy is required.");
|
||||
return row;
|
||||
}
|
||||
|
||||
requireApprovalRoute(orgId: string, socialAccountId: string, actionType: SocialBrowserAction, riskLevel: RiskLevel): SocialApprovalRouteRow {
|
||||
const row = this.db.prepare(`
|
||||
select id, approval_kind, route_to_type, route_to_id
|
||||
from social_approval_routes
|
||||
where org_id = ? and social_account_id = ? and action_type = ? and risk_level = ?
|
||||
`).get(orgId, socialAccountId, actionType, riskLevel) as SocialApprovalRouteRow | undefined;
|
||||
if (!row) throw new Error("Social approval route is required.");
|
||||
return row;
|
||||
}
|
||||
|
||||
private parseHostAllowlist(raw: string): string[] {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === "string" && item.trim())) {
|
||||
throw new Error("Browser host allowlist is invalid.");
|
||||
}
|
||||
return parsed.map((item) => item.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,17 @@ import type {
|
||||
LaunchSocialLoginInput,
|
||||
SetScheduledPostStateInput,
|
||||
} from "../../shared/commands";
|
||||
import { APPROVAL_STATE, RISK_LEVEL, SOCIAL_PLATFORM, type RiskLevel, type SocialPlatform } from "../../shared/domain";
|
||||
import { APPROVAL_STATE, RISK_LEVEL, ROUTE_TO_TYPE, SOCIAL_PLATFORM, type RiskLevel, type SocialPlatform } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { ApprovalService } from "./approval-service";
|
||||
import { AuditService } from "./audit-service";
|
||||
import {
|
||||
SOCIAL_BROWSER_ACTION,
|
||||
SocialBrowserPolicyService,
|
||||
type BrowserActionPolicyRow,
|
||||
type SocialApprovalRouteRow,
|
||||
type SocialBrowserAction,
|
||||
} from "./social-browser-policy-service";
|
||||
|
||||
const PLATFORM_LOGIN_URL: Record<SocialPlatform, string> = {
|
||||
[SOCIAL_PLATFORM.X]: "https://x.com/i/flow/login",
|
||||
@@ -18,34 +25,19 @@ const PLATFORM_LOGIN_URL: Record<SocialPlatform, string> = {
|
||||
[SOCIAL_PLATFORM.INSTAGRAM]: "https://www.instagram.com/accounts/login/",
|
||||
};
|
||||
|
||||
const ALLOWED_HOSTS: Record<SocialPlatform, string[]> = {
|
||||
[SOCIAL_PLATFORM.X]: ["x.com", "twitter.com"],
|
||||
[SOCIAL_PLATFORM.LINKEDIN]: ["linkedin.com", "www.linkedin.com"],
|
||||
[SOCIAL_PLATFORM.YOUTUBE]: ["accounts.google.com", "youtube.com", "www.youtube.com"],
|
||||
[SOCIAL_PLATFORM.INSTAGRAM]: ["instagram.com", "www.instagram.com"],
|
||||
};
|
||||
|
||||
const SOCIAL_GRANT_ACTION = {
|
||||
LOGIN: "login",
|
||||
DRAFT: "draft",
|
||||
APPROVE: "approve",
|
||||
} as const;
|
||||
|
||||
type SocialGrantAction = typeof SOCIAL_GRANT_ACTION[keyof typeof SOCIAL_GRANT_ACTION];
|
||||
|
||||
const STATE_GRANT_ACTION: Record<SetScheduledPostStateInput["state"], SocialGrantAction> = {
|
||||
draft: SOCIAL_GRANT_ACTION.DRAFT,
|
||||
scheduled: SOCIAL_GRANT_ACTION.APPROVE,
|
||||
published: SOCIAL_GRANT_ACTION.APPROVE,
|
||||
cancelled: SOCIAL_GRANT_ACTION.APPROVE,
|
||||
const STATE_GRANT_ACTION: Record<SetScheduledPostStateInput["state"], SocialBrowserAction> = {
|
||||
draft: SOCIAL_BROWSER_ACTION.DRAFT,
|
||||
scheduled: SOCIAL_BROWSER_ACTION.APPROVE,
|
||||
published: SOCIAL_BROWSER_ACTION.APPROVE,
|
||||
cancelled: SOCIAL_BROWSER_ACTION.APPROVE,
|
||||
};
|
||||
|
||||
const GRANT_SCOPE = {
|
||||
actions: [SOCIAL_GRANT_ACTION.LOGIN, SOCIAL_GRANT_ACTION.DRAFT, SOCIAL_GRANT_ACTION.APPROVE],
|
||||
actions: [SOCIAL_BROWSER_ACTION.LOGIN, SOCIAL_BROWSER_ACTION.DRAFT, SOCIAL_BROWSER_ACTION.APPROVE],
|
||||
riskCeiling: RISK_LEVEL.MEDIUM,
|
||||
} as const;
|
||||
|
||||
const ACTION_REQUIRED_RISK: Record<SocialGrantAction, RiskLevel> = {
|
||||
const ACTION_REQUIRED_RISK: Record<SocialBrowserAction, RiskLevel> = {
|
||||
login: RISK_LEVEL.LOW,
|
||||
draft: RISK_LEVEL.LOW,
|
||||
approve: RISK_LEVEL.MEDIUM,
|
||||
@@ -83,15 +75,18 @@ interface GrantScope {
|
||||
export class SocialService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly approvals: ApprovalService;
|
||||
private readonly browserPolicy: SocialBrowserPolicyService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.approvals = new ApprovalService(db);
|
||||
this.browserPolicy = new SocialBrowserPolicyService(db);
|
||||
}
|
||||
|
||||
createScheduledPost(input: CreateScheduledPostInput): string {
|
||||
this.requireAccount(input.orgId, input.socialAccountId);
|
||||
this.requireGrant(input.orgId, input.socialAccountId, input.createdByEmployeeId, SOCIAL_GRANT_ACTION.DRAFT);
|
||||
this.browserPolicy.requireActionPolicy(input.orgId, input.socialAccountId, SOCIAL_BROWSER_ACTION.DRAFT);
|
||||
this.requireGrant(input.orgId, input.socialAccountId, input.createdByEmployeeId, SOCIAL_BROWSER_ACTION.DRAFT);
|
||||
const body = this.requireText(input.bodyMd, "Post body is required.");
|
||||
const scheduledFor = this.requireSchedule(input.scheduledFor);
|
||||
const id = createId("spost");
|
||||
@@ -127,9 +122,11 @@ export class SocialService {
|
||||
|
||||
setScheduledPostState(input: SetScheduledPostStateInput): void {
|
||||
const post = this.requireScheduledPost(input.orgId, input.scheduledPostId);
|
||||
this.requireGrant(input.orgId, post.social_account_id, input.employeeId, STATE_GRANT_ACTION[input.state]);
|
||||
const action = STATE_GRANT_ACTION[input.state];
|
||||
const policy = this.browserPolicy.requireActionPolicy(input.orgId, post.social_account_id, action);
|
||||
this.requireGrant(input.orgId, post.social_account_id, input.employeeId, action);
|
||||
this.assertStateTransition(post, input.state);
|
||||
const approvalId = this.resolveApprovalForState(input, post);
|
||||
const approvalId = this.resolveApprovalForState(input, post, policy);
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare(`
|
||||
update scheduled_posts set state = ?, approval_id = ?, updated_at = ?
|
||||
@@ -171,6 +168,13 @@ export class SocialService {
|
||||
input.authorizedEmployeeId,
|
||||
now,
|
||||
);
|
||||
this.browserPolicy.seedAccountControls({
|
||||
orgId: input.orgId,
|
||||
socialAccountId: id,
|
||||
platform: input.platform,
|
||||
authorizedEmployeeId: input.authorizedEmployeeId ?? null,
|
||||
createdAt: now,
|
||||
});
|
||||
if (input.authorizedEmployeeId) {
|
||||
this.db.prepare(`
|
||||
insert into social_account_grants(id, org_id, social_account_id, employee_id, role, scope_json, created_at)
|
||||
@@ -199,9 +203,10 @@ export class SocialService {
|
||||
|
||||
launchLoginWindow(input: LaunchSocialLoginInput): void {
|
||||
const account = this.requireAccount(input.orgId, input.socialAccountId);
|
||||
this.requireGrant(input.orgId, input.socialAccountId, input.employeeId, SOCIAL_GRANT_ACTION.LOGIN);
|
||||
const partition = `persist:agentcompany-social-${account.id}`;
|
||||
const socialSession = session.fromPartition(partition);
|
||||
const container = this.browserPolicy.requireSessionContainer(input.orgId, input.socialAccountId);
|
||||
this.browserPolicy.requireActionPolicy(input.orgId, input.socialAccountId, SOCIAL_BROWSER_ACTION.LOGIN);
|
||||
this.requireGrant(input.orgId, input.socialAccountId, input.employeeId, SOCIAL_BROWSER_ACTION.LOGIN);
|
||||
const socialSession = session.fromPartition(container.partition_key);
|
||||
socialSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
|
||||
socialSession.setPermissionCheckHandler(() => false);
|
||||
|
||||
@@ -213,15 +218,17 @@ export class SocialService {
|
||||
session: socialSession,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
sandbox: container.sandbox_enabled === 1,
|
||||
},
|
||||
});
|
||||
this.attachNavigationPolicy(win, account.platform);
|
||||
this.attachNavigationPolicy(win, this.browserPolicy.allowedHosts(container));
|
||||
win.loadURL(PLATFORM_LOGIN_URL[account.platform]);
|
||||
const now = new Date().toISOString();
|
||||
this.browserPolicy.markSessionUsed(container.id, now);
|
||||
this.db.prepare(`
|
||||
insert into browser_actions(id, org_id, social_account_id, action_type, policy_decision, evidence_file_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(createId("bact"), account.org_id, account.id, "login_window_opened", "allowed_human_login", null, new Date().toISOString());
|
||||
`).run(createId("bact"), account.org_id, account.id, "login_window_opened", "allowed_human_login", null, now);
|
||||
this.audit.record({
|
||||
orgId: account.org_id,
|
||||
entityType: "social_account",
|
||||
@@ -233,10 +240,10 @@ export class SocialService {
|
||||
});
|
||||
}
|
||||
|
||||
private attachNavigationPolicy(win: BrowserWindow, platform: SocialPlatform): void {
|
||||
private attachNavigationPolicy(win: BrowserWindow, allowedHosts: string[]): void {
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
const guard = (event: Electron.Event, url: string) => {
|
||||
if (!this.isAllowedNavigation(platform, url)) event.preventDefault();
|
||||
if (!this.isAllowedNavigation(allowedHosts, url)) event.preventDefault();
|
||||
};
|
||||
win.webContents.on("will-navigate", guard);
|
||||
win.webContents.on("will-redirect", guard);
|
||||
@@ -256,7 +263,7 @@ export class SocialService {
|
||||
return post;
|
||||
}
|
||||
|
||||
private requireGrant(orgId: string, socialAccountId: string, employeeId: string, action: SocialGrantAction): void {
|
||||
private requireGrant(orgId: string, socialAccountId: string, employeeId: string, action: SocialBrowserAction): void {
|
||||
this.assertEmployee(orgId, employeeId, "Social account grant is required.");
|
||||
const row = this.db.prepare(`
|
||||
select scope_json from social_account_grants
|
||||
@@ -270,27 +277,52 @@ export class SocialService {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveApprovalForState(input: SetScheduledPostStateInput, post: ScheduledPostRow): string | null {
|
||||
private resolveApprovalForState(input: SetScheduledPostStateInput, post: ScheduledPostRow, policy: BrowserActionPolicyRow): string | null {
|
||||
if (input.state === "scheduled") {
|
||||
return post.approval_id ?? this.createPublishApproval(input.orgId, input.employeeId, post.body_md);
|
||||
return policy.approval_required === 1
|
||||
? post.approval_id ?? this.createPublishApproval(input.orgId, input.employeeId, post, policy)
|
||||
: post.approval_id;
|
||||
}
|
||||
if (input.state === "published") {
|
||||
this.assertApprovalApproved(input.orgId, post.approval_id);
|
||||
if (policy.approval_required === 1) this.assertApprovalApproved(input.orgId, post.approval_id);
|
||||
return post.approval_id;
|
||||
}
|
||||
return post.approval_id;
|
||||
}
|
||||
|
||||
private createPublishApproval(orgId: string, employeeId: string, body: string): string {
|
||||
return this.approvals.create({
|
||||
private createPublishApproval(orgId: string, employeeId: string, post: ScheduledPostRow, policy: BrowserActionPolicyRow): string {
|
||||
const route = this.browserPolicy.requireApprovalRoute(orgId, post.social_account_id, policy.action_type, policy.risk_level);
|
||||
const approvalId = this.approvals.create({
|
||||
orgId,
|
||||
kind: "social_publish",
|
||||
kind: route.approval_kind,
|
||||
title: "Approve social post",
|
||||
details: body,
|
||||
risk: RISK_LEVEL.MEDIUM,
|
||||
details: post.body_md,
|
||||
risk: policy.risk_level,
|
||||
requestedByType: "employee",
|
||||
requestedById: employeeId,
|
||||
});
|
||||
this.assignSocialApprovalRoute(orgId, approvalId, route);
|
||||
return approvalId;
|
||||
}
|
||||
|
||||
private assignSocialApprovalRoute(orgId: string, approvalId: string, route: SocialApprovalRouteRow): void {
|
||||
if (route.route_to_type !== "authorized_employee") throw new Error("Social approval route target type is invalid.");
|
||||
if (!route.route_to_id) throw new Error("Social approval route target is required.");
|
||||
this.assertEmployee(orgId, route.route_to_id, "Social approval route employee was not found.");
|
||||
this.db.prepare(`
|
||||
insert into approval_assignments(id, org_id, approval_id, assignee_type, assignee_id, routing_rule_id, created_at)
|
||||
values (?, ?, ?, ?, ?, null, ?)
|
||||
on conflict(approval_id, assignee_type, assignee_id) do nothing
|
||||
`).run(createId("aas"), orgId, approvalId, ROUTE_TO_TYPE.EMPLOYEE, route.route_to_id, new Date().toISOString());
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "approval",
|
||||
entityId: approvalId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "social_route_assigned",
|
||||
payload: { routeId: route.id, routeToType: route.route_to_type, routeToId: route.route_to_id },
|
||||
});
|
||||
}
|
||||
|
||||
private assertApprovalApproved(orgId: string, approvalId: string | null): void {
|
||||
@@ -345,11 +377,11 @@ export class SocialService {
|
||||
return scheduledFor;
|
||||
}
|
||||
|
||||
private isAllowedNavigation(platform: SocialPlatform, url: string): boolean {
|
||||
private isAllowedNavigation(allowedHosts: string[], url: string): boolean {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return ALLOWED_HOSTS[platform].some((entry) => host === entry || host.endsWith(`.${entry}`));
|
||||
return allowedHosts.some((entry) => host === entry || host.endsWith(`.${entry}`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ProviderRequestRecord } from "./provider-request-domain";
|
||||
import type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } from "./provider-runtime-domain";
|
||||
import type { RollbackSnapshotRecord } from "./rollback-domain";
|
||||
import type { EvalCaseRecord, EvalRunRecord, EvalSuiteRecord, PrBotGateRecord, SdlcReviewAgentRecord, SdlcReviewRunRecord } from "./sdlc-domain";
|
||||
import type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord } from "./social-domain";
|
||||
export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain";
|
||||
export type { AuditReplayRunRecord } from "./audit-domain";
|
||||
export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain";
|
||||
@@ -23,6 +24,7 @@ export type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord }
|
||||
export type { RollbackSnapshotRecord } from "./rollback-domain";
|
||||
export { EVAL_SUITE_KEY, PR_BOT_GATE_KEY, SDLC_REVIEW_AGENT_KEY, SDLC_REVIEW_TARGET_TYPE, SDLC_RUN_STATE, type EvalCaseRecord, type EvalRunRecord, type EvalSuiteKey, type EvalSuiteRecord, type PrBotGateKey, type PrBotGateRecord, type SdlcReviewAgentKey, type SdlcReviewAgentRecord, type SdlcReviewRunRecord, type SdlcReviewTargetType, type SdlcRunState } from "./sdlc-domain";
|
||||
export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain";
|
||||
export type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord } from "./social-domain";
|
||||
export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const;
|
||||
export const APPROVAL_STATE = { PENDING: "pending", APPROVED: "approved", DECLINED: "declined" } as const;
|
||||
export const RISK_LEVEL = { LOW: "low", MEDIUM: "medium", HIGH: "high" } as const;
|
||||
@@ -376,36 +378,6 @@ export interface SkillRuntimeSnapshotRecord {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SocialAccountRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
platform: SocialPlatform;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
authorizedEmployeeId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SocialAccountGrantRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
socialAccountId: string;
|
||||
employeeId: string;
|
||||
role: string;
|
||||
scopeJson: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BrowserActionRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
socialAccountId: string;
|
||||
actionType: string;
|
||||
policyDecision: string;
|
||||
evidenceFileId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuditEventRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
@@ -473,6 +445,9 @@ export interface AppSnapshot {
|
||||
skillRuntimeSnapshots: SkillRuntimeSnapshotRecord[];
|
||||
socialAccounts: SocialAccountRecord[];
|
||||
socialAccountGrants: SocialAccountGrantRecord[];
|
||||
browserSessionContainers: BrowserSessionContainerRecord[];
|
||||
browserActionPolicies: BrowserActionPolicyRecord[];
|
||||
socialApprovalRoutes: SocialApprovalRouteRecord[];
|
||||
browserActions: BrowserActionRecord[];
|
||||
policyRules: PolicyRuleRecord[];
|
||||
migrationReports: MigrationReportRecord[];
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { SocialPlatform } from "./domain";
|
||||
|
||||
export interface SocialAccountRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
platform: SocialPlatform;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
authorizedEmployeeId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SocialAccountGrantRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
socialAccountId: string;
|
||||
employeeId: string;
|
||||
role: string;
|
||||
scopeJson: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BrowserSessionContainerRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
socialAccountId: string;
|
||||
platform: SocialPlatform;
|
||||
partitionKey: string;
|
||||
hostAllowlistJson: string;
|
||||
permissionPolicy: string;
|
||||
sandboxEnabled: boolean;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export interface BrowserActionPolicyRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
socialAccountId: string;
|
||||
actionType: string;
|
||||
contentCategory: string;
|
||||
riskLevel: string;
|
||||
approvalRequired: boolean;
|
||||
approvalKind: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SocialApprovalRouteRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
socialAccountId: string;
|
||||
actionType: string;
|
||||
riskLevel: string;
|
||||
approvalKind: string;
|
||||
routeToType: string;
|
||||
routeToId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BrowserActionRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
socialAccountId: string;
|
||||
actionType: string;
|
||||
policyDecision: string;
|
||||
evidenceFileId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
Reference in new issue
Block a user