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
79 changes: 79 additions & 0 deletions services/actions/src/rpc/emitBranchLifecycle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { fetchGraphQL } from "../utils/graphql.js";
import { emitLifecycleEvent } from "../utils/semanticEvents.js";

/**
* emit_branch_lifecycle — Hasura event-trigger handler (099 US7, FR-091, T087).
*
* Fires on `branches` INSERT and DELETE (both raw-GraphQL, no JS chokepoint). One
* handler covers both ops: INSERT → `Branch Created`, DELETE → `Branch Deleted`.
* Emits the canonical lifecycle event to the FraiOS ingress via the never-throw
* `emitLifecycleEvent` substrate.
*
* Tenant: partition == the owning synmetrix team (team.settings.partition);
* accountId == the team id — resolved from the branch's datasource → team
* (admin-secret read). On DELETE the tenant is resolved from the OLD row's
* datasource_id (the datasource itself is not cascaded away by a branch delete).
* Emission is fire-and-forget and NEVER blocks or fails the trigger (FR-007).
*/
const DATASOURCE_TENANT = `
query DatasourceTenant($id: uuid!) {
datasources_by_pk(id: $id) {
id
name
team_id
team { id name settings }
}
}
`;

export default async (session, input) => {
const op = input?.event?.op; // "INSERT" | "DELETE" | "MANUAL"
const isDelete = op === "DELETE";
const row = isDelete ? input?.event?.data?.old : input?.event?.data?.new;
if (!row?.id || !row.datasource_id) {
return { ok: true, skipped: true };
}

let datasource = null;
try {
const res = await fetchGraphQL(DATASOURCE_TENANT, { id: row.datasource_id });
datasource = res?.data?.datasources_by_pk || null;
} catch {
// non-fatal
}

const team = datasource?.team || null;
const partition = team?.settings?.partition ?? null;
const accountId = team?.id ?? null;
if (!accountId && !partition) {
return { ok: true, skipped: true };
}

const sessionVars = input?.event?.session_variables || session || {};
const userId =
row.user_id ||
sessionVars["x-hasura-user-id"] ||
sessionVars["X-Hasura-User-Id"] ||
null;

const result = await emitLifecycleEvent({
event: isDelete ? "Branch Deleted" : "Branch Created",
partition,
accountId,
accountLabel: team?.name ?? null,
userId,
about: {
entity_type: "Data Model",
id: row.id,
label: row.name ?? null,
},
status: isDelete ? "deleted" : "created",
properties: {
branch_name: row.name ?? null,
datasource_id: row.datasource_id,
branch_status: row.status ?? null,
},
});

return { ok: true, emitted: !!result?.ok, skipped: !!result?.skipped };
};
85 changes: 85 additions & 0 deletions services/actions/src/rpc/emitModelVersionCreated.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { fetchGraphQL } from "../utils/graphql.js";
import { emitLifecycleEvent } from "../utils/semanticEvents.js";

/**
* emit_model_version_created — Hasura event-trigger handler (099 US7, FR-091, T087).
*
* Fires AFTER a `versions.insert` commits. The editor's "pure save" and other
* raw-GraphQL version creations never pass through a cubejs code chokepoint, so
* this trigger is the only place the version-creation fact can be observed. It
* emits the canonical `Model Version Created` lifecycle event to the FraiOS
* ingress via the never-throw `emitLifecycleEvent` substrate.
*
* Tenant: partition == the owning synmetrix team (team.settings.partition — the
* exact key the semantic_events RLS filters on); accountId == the team id. Both
* are resolved from the version's branch → datasource → team (admin-secret read,
* so team.settings is visible). Emission is fire-and-forget and NEVER blocks or
* fails the trigger (FR-007).
*/
const BRANCH_TENANT = `
query BranchTenant($id: uuid!) {
branches_by_pk(id: $id) {
id
name
datasource_id
datasource {
id
name
team_id
team { id name settings }
}
}
}
`;

export default async (session, input) => {
const row = input?.event?.data?.new;
if (!row?.id || !row.branch_id) {
return { ok: true, skipped: true };
}

let branch = null;
try {
const res = await fetchGraphQL(BRANCH_TENANT, { id: row.branch_id });
branch = res?.data?.branches_by_pk || null;
} catch {
// non-fatal — never block the trigger on a tenant lookup
}

const team = branch?.datasource?.team || null;
const partition = team?.settings?.partition ?? null;
const accountId = team?.id ?? null;
if (!accountId && !partition) {
// No resolvable tenant → do not file under a synthetic one (A4).
return { ok: true, skipped: true };
}

const sessionVars = input?.event?.session_variables || session || {};
const userId =
row.user_id ||
sessionVars["x-hasura-user-id"] ||
sessionVars["X-Hasura-User-Id"] ||
null;

const result = await emitLifecycleEvent({
event: "Model Version Created",
partition,
accountId,
accountLabel: team?.name ?? null,
userId,
about: {
entity_type: "Data Model",
id: row.branch_id,
label: branch?.name ?? null,
},
status: "created",
properties: {
version_id: row.id,
datasource_id: branch?.datasource_id ?? null,
origin: row.origin ?? null,
checksum: row.checksum ?? null,
},
});

return { ok: true, emitted: !!result?.ok, skipped: !!result?.skipped };
};
80 changes: 80 additions & 0 deletions services/actions/src/rpc/emitSqlCredentialLifecycle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { fetchGraphQL } from "../utils/graphql.js";
import { emitLifecycleEvent } from "../utils/semanticEvents.js";

/**
* emit_sql_credential_lifecycle — Hasura event-trigger handler (099 US7, FR-091, T089).
*
* Fires on `sql_credentials` INSERT and DELETE (raw-GraphQL, no JS chokepoint).
* INSERT → `SQL Credential Created`, DELETE → `SQL Credential Deleted`. Emits the
* canonical lifecycle event to the FraiOS ingress via the never-throw
* `emitLifecycleEvent` substrate.
*
* Tenant: partition == the owning synmetrix team (team.settings.partition);
* accountId == the team id — resolved from the credential's datasource → team
* (admin-secret read). A legacy credential whose datasource has no FraiOS tenant
* (no team / no partition) is skipped rather than filed under a synthetic tenant
* (A4). ABOUT carries the credential id under the Secret family; the secret value
* is NEVER included. Emission is fire-and-forget and never fails the trigger (FR-007).
*/
const DATASOURCE_TENANT = `
query DatasourceTenant($id: uuid!) {
datasources_by_pk(id: $id) {
id
name
team_id
team { id name settings }
}
}
`;

export default async (session, input) => {
const op = input?.event?.op; // "INSERT" | "DELETE" | "MANUAL"
const isDelete = op === "DELETE";
const row = isDelete ? input?.event?.data?.old : input?.event?.data?.new;
if (!row?.id || !row.datasource_id) {
return { ok: true, skipped: true };
}

let datasource = null;
try {
const res = await fetchGraphQL(DATASOURCE_TENANT, { id: row.datasource_id });
datasource = res?.data?.datasources_by_pk || null;
} catch {
// non-fatal
}

const team = datasource?.team || null;
const partition = team?.settings?.partition ?? null;
const accountId = team?.id ?? null;
if (!accountId && !partition) {
// Legacy credential with no FraiOS tenant → skip (A4).
return { ok: true, skipped: true };
}

const sessionVars = input?.event?.session_variables || session || {};
const userId =
row.user_id ||
sessionVars["x-hasura-user-id"] ||
sessionVars["X-Hasura-User-Id"] ||
null;

const result = await emitLifecycleEvent({
event: isDelete ? "SQL Credential Deleted" : "SQL Credential Created",
partition,
accountId,
accountLabel: team?.name ?? null,
userId,
about: {
entity_type: "Secret",
id: row.id,
label: row.username ?? null, // username only — the secret value is never emitted
},
status: isDelete ? "deleted" : "created",
properties: {
datasource_id: row.datasource_id,
username: row.username ?? null,
},
});

return { ok: true, emitted: !!result?.ok, skipped: !!result?.skipped };
};
32 changes: 31 additions & 1 deletion services/actions/src/rpc/manageQueryRewriteRule.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@ import apiError from "../utils/apiError.js";
import { invalidateRulesCache } from "../utils/cubeCache.js";
import { fetchGraphQL } from "../utils/graphql.js";
import { isPortalAdmin } from "../utils/portalAdmin.js";
import { emitLifecycleEvent } from "../utils/semanticEvents.js";

// 099 US7 (FR-091, T089): row-level Access Rules (query_rewrite_rules) are
// PLATFORM-GLOBAL config edited only by portal admins — there is no per-tenant
// row. They are attributed to the platform tenant (partition), overridable via
// PLATFORM_PARTITION; ACTED_BY carries the acting admin. Emission is
// fire-and-forget and never affects the mutation (FR-007).
const PLATFORM_PARTITION = process.env.PLATFORM_PARTITION || "fftech.is";

async function emitAccessRule(event, status, ruleId, userId, properties) {
await emitLifecycleEvent({
event,
partition: PLATFORM_PARTITION,
userId,
about: { entity_type: "Policy", id: ruleId, label: properties?.cube_name ?? null },
status,
properties,
});
}

const insertRuleMutation = `
mutation InsertRule($object: query_rewrite_rules_insert_input!) {
Expand Down Expand Up @@ -84,7 +103,16 @@ export default async (session, input) => {
});

const ruleId = res?.data?.insert_query_rewrite_rules_one?.id;
if (ruleId) invalidateRulesCache();
if (ruleId) {
invalidateRulesCache();
void emitAccessRule("Access Rule Created", "created", ruleId, userId, {
cube_name,
dimension,
property_source,
property_key,
operator: op,
});
}
return { success: !!ruleId, rule_id: ruleId || null };
}

Expand Down Expand Up @@ -119,6 +147,7 @@ export default async (session, input) => {

await fetchGraphQL(updateRuleMutation, { id, set: updates });
invalidateRulesCache();
void emitAccessRule("Access Rule Updated", "updated", id, userId, updates);
return { success: true, rule_id: id };
}

Expand All @@ -133,6 +162,7 @@ export default async (session, input) => {

await fetchGraphQL(deleteRuleMutation, { id });
invalidateRulesCache();
void emitAccessRule("Access Rule Deleted", "deleted", id, userId, null);
return { success: true, rule_id: id };
}

Expand Down
Loading
Loading