mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 06:56:44 +00:00
Concluída segurança e deploy
X-Lovable-Edit-ID: edt-0ebc59f9-09e4-4c59-b945-a824caba53e6 Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
3651c25b27
12 changed files with 523 additions and 5 deletions
|
|
@ -6,8 +6,8 @@ verify_jwt = false
|
||||||
[functions.get-paddle-price]
|
[functions.get-paddle-price]
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
|
|
||||||
[functions.generate-skill-markdown]
|
# generate-skill-markdown: verify_jwt = true (default). Chamada do painel via
|
||||||
verify_jwt = false
|
# supabase.functions.invoke já anexa o JWT do usuário autenticado.
|
||||||
|
|
||||||
[functions.test-skill-dry-run]
|
[functions.test-skill-dry-run]
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
|
|
|
||||||
128
supabase/functions/_shared/internal-auth.ts
Normal file
128
supabase/functions/_shared/internal-auth.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
// internal-auth.ts
|
||||||
|
// Helper compartilhado para validar chamadas internas a edge functions.
|
||||||
|
// Permite que uma function aceite:
|
||||||
|
// - X-Internal-Secret válido (chamadas server-to-server, triggers pg_net, cron)
|
||||||
|
// - JWT de admin (painel admin via supabase.functions.invoke)
|
||||||
|
// - opcionalmente, JWT do dono do recurso (ownerUserId)
|
||||||
|
//
|
||||||
|
// Modo de operação:
|
||||||
|
// - Se INTERNAL_FUNCTION_SECRET estiver definido E o header X-Internal-Secret bater → OK
|
||||||
|
// - Senão, tenta validar o JWT do Authorization header
|
||||||
|
// - Retorna { ok, userId, isAdmin, isOwner, reason }
|
||||||
|
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||||
|
|
||||||
|
export interface InternalAuthResult {
|
||||||
|
ok: boolean;
|
||||||
|
userId: string | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isOwner: boolean;
|
||||||
|
viaSecret: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorizeInternalOptions {
|
||||||
|
/** Se fornecido, valida ownership contra esse user_id. */
|
||||||
|
ownerUserId?: string | null;
|
||||||
|
/** Se true (default), permite admins. */
|
||||||
|
allowAdmin?: boolean;
|
||||||
|
/** Se true (default), permite o dono. */
|
||||||
|
allowOwner?: boolean;
|
||||||
|
/** Se true (default), permite via X-Internal-Secret. */
|
||||||
|
allowSecret?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authorizeInternalRequest(
|
||||||
|
req: Request,
|
||||||
|
opts: AuthorizeInternalOptions = {},
|
||||||
|
): Promise<InternalAuthResult> {
|
||||||
|
const {
|
||||||
|
ownerUserId = null,
|
||||||
|
allowAdmin = true,
|
||||||
|
allowOwner = true,
|
||||||
|
allowSecret = true,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
// 1) X-Internal-Secret
|
||||||
|
if (allowSecret) {
|
||||||
|
const expected = Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? "";
|
||||||
|
const received = req.headers.get("x-internal-secret") ?? "";
|
||||||
|
if (expected && received && constantTimeEq(expected, received)) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
userId: null,
|
||||||
|
isAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
|
viaSecret: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) JWT
|
||||||
|
const authHeader = req.headers.get("authorization") ?? "";
|
||||||
|
if (!authHeader.toLowerCase().startsWith("bearer ")) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
userId: null,
|
||||||
|
isAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
|
viaSecret: false,
|
||||||
|
reason: "missing bearer token",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||||
|
const anonKey = Deno.env.get("SUPABASE_ANON_KEY") ?? Deno.env.get("SUPABASE_PUBLISHABLE_KEY") ?? "";
|
||||||
|
|
||||||
|
const userClient = createClient(supabaseUrl, anonKey, {
|
||||||
|
global: { headers: { Authorization: authHeader } },
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
const { data: userData, error: userErr } = await userClient.auth.getUser();
|
||||||
|
if (userErr || !userData?.user) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
userId: null,
|
||||||
|
isAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
|
viaSecret: false,
|
||||||
|
reason: "invalid jwt",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const userId = userData.user.id;
|
||||||
|
|
||||||
|
let isAdmin = false;
|
||||||
|
if (allowAdmin) {
|
||||||
|
const admin = createClient(supabaseUrl, serviceKey, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
const { data: roleOk } = await admin.rpc("has_role", {
|
||||||
|
_user_id: userId,
|
||||||
|
_role: "admin",
|
||||||
|
});
|
||||||
|
isAdmin = roleOk === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = allowOwner && ownerUserId !== null && userId === ownerUserId;
|
||||||
|
|
||||||
|
if (isAdmin || isOwner) {
|
||||||
|
return { ok: true, userId, isAdmin, isOwner, viaSecret: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
userId,
|
||||||
|
isAdmin,
|
||||||
|
isOwner,
|
||||||
|
viaSecret: false,
|
||||||
|
reason: "forbidden",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function constantTimeEq(a: string, b: string): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
96
supabase/functions/bootstrap-internal-secret/index.ts
Normal file
96
supabase/functions/bootstrap-internal-secret/index.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// bootstrap-internal-secret
|
||||||
|
// Função one-shot: lê INTERNAL_FUNCTION_SECRET de env e persiste em vault como
|
||||||
|
// 'internal_function_secret', para que triggers pg_net e cron job possam ler e
|
||||||
|
// enviar como X-Internal-Secret nas chamadas a outras edge functions.
|
||||||
|
//
|
||||||
|
// Requer JWT de admin. Idempotente — pode ser chamada várias vezes.
|
||||||
|
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||||
|
import { corsHeaders } from "../_shared/cors.ts";
|
||||||
|
|
||||||
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||||
|
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? Deno.env.get("SUPABASE_PUBLISHABLE_KEY") ?? "";
|
||||||
|
|
||||||
|
function json(status: number, body: unknown) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||||
|
|
||||||
|
const secretValue = Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? "";
|
||||||
|
if (!secretValue) {
|
||||||
|
return json(500, { error: "INTERNAL_FUNCTION_SECRET not set in env" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar admin via JWT
|
||||||
|
const authHeader = req.headers.get("authorization") ?? "";
|
||||||
|
if (!authHeader.toLowerCase().startsWith("bearer ")) {
|
||||||
|
return json(401, { error: "missing bearer token" });
|
||||||
|
}
|
||||||
|
const userClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||||
|
global: { headers: { Authorization: authHeader } },
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
const { data: userData, error: userErr } = await userClient.auth.getUser();
|
||||||
|
if (userErr || !userData?.user) return json(401, { error: "invalid jwt" });
|
||||||
|
|
||||||
|
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
const { data: roleOk } = await admin.rpc("has_role", {
|
||||||
|
_user_id: userData.user.id,
|
||||||
|
_role: "admin",
|
||||||
|
});
|
||||||
|
if (!roleOk) return json(403, { error: "admin role required" });
|
||||||
|
|
||||||
|
// Upsert no vault. Como vault.create_secret falha em nomes duplicados,
|
||||||
|
// tentamos atualizar existente primeiro via SQL direto.
|
||||||
|
try {
|
||||||
|
// 1) Tenta achar entry existente
|
||||||
|
const { data: existing } = await admin
|
||||||
|
.from("vault.decrypted_secrets" as never)
|
||||||
|
.select("id, decrypted_secret")
|
||||||
|
.eq("name", "internal_function_secret")
|
||||||
|
.maybeSingle()
|
||||||
|
.returns<{ id: string; decrypted_secret: string } | null>();
|
||||||
|
|
||||||
|
if (existing && existing.decrypted_secret === secretValue) {
|
||||||
|
return json(200, { ok: true, action: "already_synced" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Atualiza o valor via RPC dedicada (não temos UPDATE direto em vault.secrets via PostgREST,
|
||||||
|
// então deletamos e recriamos).
|
||||||
|
await admin.rpc("vault_delete_secret", { secret_id: existing.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: created, error: createErr } = await admin
|
||||||
|
.rpc("vault_create_secret", {
|
||||||
|
secret_value: secretValue,
|
||||||
|
secret_name: "internal_function_secret",
|
||||||
|
secret_description: "Shared secret for trigger pg_net → edge functions auth",
|
||||||
|
})
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (createErr) {
|
||||||
|
console.error("vault_create_secret failed:", createErr);
|
||||||
|
return json(500, { error: "vault create failed", detail: createErr.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(200, {
|
||||||
|
ok: true,
|
||||||
|
action: existing ? "rotated" : "created",
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
secret_id: (created as any)?.secret_id ?? created,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("bootstrap-internal-secret fatal:", msg);
|
||||||
|
return json(500, { error: msg });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -114,6 +114,7 @@ Deno.serve(async (req) => {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${serviceKey}`,
|
Authorization: `Bearer ${serviceKey}`,
|
||||||
|
"X-Internal-Secret": Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? "",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
agent_instance_id: agent.id,
|
agent_instance_id: agent.id,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
|
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||||
import { corsHeaders } from "../_shared/cors.ts";
|
import { corsHeaders } from "../_shared/cors.ts";
|
||||||
|
import { authorizeInternalRequest } from "../_shared/internal-auth.ts";
|
||||||
import { pullAgentCronjobsRuntimeState } from "../_shared/runtime-sync.ts";
|
import { pullAgentCronjobsRuntimeState } from "../_shared/runtime-sync.ts";
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
|
@ -31,6 +32,13 @@ interface AgentRow {
|
||||||
Deno.serve(async (req) => {
|
Deno.serve(async (req) => {
|
||||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||||
|
|
||||||
|
// Auth: aceita X-Internal-Secret (pg_cron) OU JWT de admin.
|
||||||
|
const auth = await authorizeInternalRequest(req, { allowOwner: false });
|
||||||
|
if (!auth.ok) {
|
||||||
|
console.warn(`keep-alive: auth rejected (${auth.reason})`);
|
||||||
|
return jsonResponse(401, { error: "unauthorized", reason: auth.reason });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||||
auth: { persistSession: false, autoRefreshToken: false },
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,20 @@ Deno.serve(async (req) => {
|
||||||
return ack();
|
return ack();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verificação de origem: se TELEGRAM_MANAGER_BOT_WEBHOOK_SECRET estiver
|
||||||
|
// configurado, exige o header X-Telegram-Bot-Api-Secret-Token correspondente.
|
||||||
|
// Caso contrário, opera em modo permissivo (comportamento anterior) + log.
|
||||||
|
const expectedSecret = Deno.env.get("TELEGRAM_MANAGER_BOT_WEBHOOK_SECRET") ?? "";
|
||||||
|
if (expectedSecret) {
|
||||||
|
const incoming = req.headers.get("X-Telegram-Bot-Api-Secret-Token") ?? "";
|
||||||
|
if (incoming !== expectedSecret) {
|
||||||
|
console.warn("managed-bot-webhook: invalid telegram secret token");
|
||||||
|
return new Response("unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("managed-bot-webhook: TELEGRAM_MANAGER_BOT_WEBHOOK_SECRET ausente — modo permissivo");
|
||||||
|
}
|
||||||
|
|
||||||
if (!managerToken) {
|
if (!managerToken) {
|
||||||
console.error("TELEGRAM_MANAGER_BOT_TOKEN ausente");
|
console.error("TELEGRAM_MANAGER_BOT_TOKEN ausente");
|
||||||
return ack();
|
return ack();
|
||||||
|
|
@ -165,6 +179,7 @@ Deno.serve(async (req) => {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${serviceKey}`,
|
Authorization: `Bearer ${serviceKey}`,
|
||||||
|
"X-Internal-Secret": Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? "",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
agent_instance_id: agentInstance.id,
|
agent_instance_id: agentInstance.id,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||||
import { corsHeaders } from "../_shared/cors.ts";
|
import { corsHeaders } from "../_shared/cors.ts";
|
||||||
|
import { authorizeInternalRequest } from "../_shared/internal-auth.ts";
|
||||||
import {
|
import {
|
||||||
HERMES_START_COMMAND,
|
HERMES_START_COMMAND,
|
||||||
createRailwayService,
|
createRailwayService,
|
||||||
|
|
@ -60,6 +61,13 @@ async function notifyAdmin(message: string): Promise<void> {
|
||||||
Deno.serve(async (req) => {
|
Deno.serve(async (req) => {
|
||||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||||
|
|
||||||
|
// Auth: aceita X-Internal-Secret (trigger pg_net / chamadas internas) OU JWT de admin.
|
||||||
|
const auth = await authorizeInternalRequest(req, { allowOwner: false });
|
||||||
|
if (!auth.ok) {
|
||||||
|
console.warn(`[provision-agent] auth rejected: ${auth.reason}`);
|
||||||
|
return jsonResponse(401, { error: "unauthorized", reason: auth.reason });
|
||||||
|
}
|
||||||
|
|
||||||
if (!RAILWAY_API_TOKEN) {
|
if (!RAILWAY_API_TOKEN) {
|
||||||
return jsonResponse(500, { error: "RAILWAY_API_TOKEN not configured" });
|
return jsonResponse(500, { error: "RAILWAY_API_TOKEN not configured" });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,39 @@ Deno.serve(async (req) => {
|
||||||
return jsonResponse(200, { ignored: true, reason: "method not allowed" });
|
return jsonResponse(200, { ignored: true, reason: "method not allowed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawBody = await req.text();
|
||||||
|
|
||||||
|
// Verificação de assinatura: modo permissivo se RAILWAY_WEBHOOK_SECRET não estiver
|
||||||
|
// configurado (mantém comportamento atual). Quando configurado, exige HMAC SHA-256.
|
||||||
|
const railwaySecret = Deno.env.get("RAILWAY_WEBHOOK_SECRET") ?? "";
|
||||||
|
if (railwaySecret) {
|
||||||
|
const sig = req.headers.get("X-Railway-Signature") ?? "";
|
||||||
|
try {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
new TextEncoder().encode(railwaySecret),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign"],
|
||||||
|
);
|
||||||
|
const macBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(rawBody));
|
||||||
|
const macHex = Array.from(new Uint8Array(macBuf)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
const expected = sig.startsWith("sha256=") ? sig.slice(7) : sig;
|
||||||
|
if (expected !== macHex) {
|
||||||
|
console.warn("railway-webhook: invalid signature");
|
||||||
|
return jsonResponse(401, { error: "invalid signature" });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("railway-webhook: signature verify failed:", String(e));
|
||||||
|
return jsonResponse(401, { error: "signature verification failed" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("railway-webhook: RAILWAY_WEBHOOK_SECRET ausente — modo permissivo");
|
||||||
|
}
|
||||||
|
|
||||||
let payload: Record<string, unknown>;
|
let payload: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
payload = await req.json();
|
payload = JSON.parse(rawBody);
|
||||||
} catch {
|
} catch {
|
||||||
console.log("railway-webhook: invalid json body");
|
console.log("railway-webhook: invalid json body");
|
||||||
return jsonResponse(200, { ignored: true, reason: "invalid json" });
|
return jsonResponse(200, { ignored: true, reason: "invalid json" });
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||||
import { corsHeaders } from "../_shared/cors.ts";
|
import { corsHeaders } from "../_shared/cors.ts";
|
||||||
|
import { authorizeInternalRequest } from "../_shared/internal-auth.ts";
|
||||||
import { setHermesSuspended, getServiceContext } from "../_shared/railway.ts";
|
import { setHermesSuspended, getServiceContext } from "../_shared/railway.ts";
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
|
@ -39,12 +40,20 @@ Deno.serve(async (req) => {
|
||||||
|
|
||||||
const { data: agent } = await supabase
|
const { data: agent } = await supabase
|
||||||
.from("agent_instances")
|
.from("agent_instances")
|
||||||
.select("id, status, railway_service_id, vps_pool_id")
|
.select("id, status, railway_service_id, vps_pool_id, user_id")
|
||||||
.eq("id", body.agent_instance_id)
|
.eq("id", body.agent_instance_id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (!agent) return jsonResponse(404, { error: "agent_instance not found" });
|
if (!agent) return jsonResponse(404, { error: "agent_instance not found" });
|
||||||
|
|
||||||
|
// Auth: X-Internal-Secret (trigger), JWT de admin, OU JWT do dono do agente.
|
||||||
|
const auth = await authorizeInternalRequest(req, { ownerUserId: agent.user_id });
|
||||||
|
if (!auth.ok) {
|
||||||
|
console.warn(`resume-agent: auth rejected (${auth.reason})`);
|
||||||
|
return jsonResponse(401, { error: "unauthorized", reason: auth.reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!agent.railway_service_id) {
|
if (!agent.railway_service_id) {
|
||||||
return jsonResponse(409, {
|
return jsonResponse(409, {
|
||||||
error: "agent has no container — needs full provisioning instead",
|
error: "agent has no container — needs full provisioning instead",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||||
import { corsHeaders } from "../_shared/cors.ts";
|
import { corsHeaders } from "../_shared/cors.ts";
|
||||||
|
import { authorizeInternalRequest } from "../_shared/internal-auth.ts";
|
||||||
import { setHermesSuspended, getServiceContext } from "../_shared/railway.ts";
|
import { setHermesSuspended, getServiceContext } from "../_shared/railway.ts";
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
|
@ -40,11 +41,19 @@ Deno.serve(async (req) => {
|
||||||
|
|
||||||
const { data: agent } = await supabase
|
const { data: agent } = await supabase
|
||||||
.from("agent_instances")
|
.from("agent_instances")
|
||||||
.select("id, status, railway_service_id, vps_pool_id")
|
.select("id, status, railway_service_id, vps_pool_id, user_id")
|
||||||
.eq("id", body.agent_instance_id)
|
.eq("id", body.agent_instance_id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (!agent) return jsonResponse(404, { error: "agent_instance not found" });
|
if (!agent) return jsonResponse(404, { error: "agent_instance not found" });
|
||||||
|
|
||||||
|
// Auth: X-Internal-Secret (trigger), JWT de admin, OU JWT do dono do agente.
|
||||||
|
const auth = await authorizeInternalRequest(req, { ownerUserId: agent.user_id });
|
||||||
|
if (!auth.ok) {
|
||||||
|
console.warn(`suspend-agent: auth rejected (${auth.reason})`);
|
||||||
|
return jsonResponse(401, { error: "unauthorized", reason: auth.reason });
|
||||||
|
}
|
||||||
|
|
||||||
if (agent.status === "suspended") {
|
if (agent.status === "suspended") {
|
||||||
return jsonResponse(200, { ok: true, already_suspended: true });
|
return jsonResponse(200, { ok: true, already_suspended: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,7 @@ Deno.serve(async (req) => {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${serviceKey}`,
|
Authorization: `Bearer ${serviceKey}`,
|
||||||
|
"X-Internal-Secret": Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? "",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ agent_instance_id: agent.id }),
|
body: JSON.stringify({ agent_instance_id: agent.id }),
|
||||||
}).catch((err) => console.error("auto-provision fetch error:", err));
|
}).catch((err) => console.error("auto-provision fetch error:", err));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Correções de segurança — sem impacto em produção
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
-- 1) RLS: policies service_role explícitas em tabelas backend-only
|
||||||
|
-- (silencia warnings do linter e documenta intenção)
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "service_role manages oauth_state_tokens" ON public.oauth_state_tokens;
|
||||||
|
CREATE POLICY "service_role manages oauth_state_tokens"
|
||||||
|
ON public.oauth_state_tokens
|
||||||
|
AS PERMISSIVE
|
||||||
|
FOR ALL
|
||||||
|
TO service_role
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "service_role manages paddle_webhook_events" ON public.paddle_webhook_events;
|
||||||
|
CREATE POLICY "service_role manages paddle_webhook_events"
|
||||||
|
ON public.paddle_webhook_events
|
||||||
|
AS PERMISSIVE
|
||||||
|
FOR ALL
|
||||||
|
TO service_role
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "service_role manages stripe_webhook_events" ON public.stripe_webhook_events;
|
||||||
|
CREATE POLICY "service_role manages stripe_webhook_events"
|
||||||
|
ON public.stripe_webhook_events
|
||||||
|
AS PERMISSIVE
|
||||||
|
FOR ALL
|
||||||
|
TO service_role
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "service_role manages telegram_rate_limit_bucket" ON public.telegram_rate_limit_bucket;
|
||||||
|
CREATE POLICY "service_role manages telegram_rate_limit_bucket"
|
||||||
|
ON public.telegram_rate_limit_bucket
|
||||||
|
AS PERMISSIVE
|
||||||
|
FOR ALL
|
||||||
|
TO service_role
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
-- 2) REVOKE EXECUTE em SECURITY DEFINER internas (mantém só service_role/postgres)
|
||||||
|
-- Funções de uso EXCLUSIVAMENTE interno (triggers, vault, cleanup):
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.update_updated_at_column() FROM anon, authenticated, public;
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.handle_new_user() FROM anon, authenticated, public;
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.vault_delete_secret(uuid) FROM anon, authenticated, public;
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.vault_create_secret(text, text, text) FROM anon, authenticated, public;
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.vault_decrypt_secret(uuid) FROM anon, authenticated, public;
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.enforce_skill_limit() FROM anon, authenticated, public;
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.cleanup_old_skill_versions() FROM anon, authenticated, public;
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.cleanup_expired_oauth_states() FROM anon, authenticated, public;
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.trigger_provision_agent() FROM anon, authenticated, public;
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.enforce_job_limit() FROM anon, authenticated, public;
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.trigger_suspend_or_resume_agent() FROM anon, authenticated, public;
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.enforce_integration_limit() FROM anon, authenticated, public;
|
||||||
|
|
||||||
|
-- Funções que precisam ser acessíveis a usuários autenticados (não a anon):
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.has_role(uuid, app_role) FROM anon, public;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.has_role(uuid, app_role) TO authenticated;
|
||||||
|
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.has_active_subscription(uuid, text) FROM anon, public;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.has_active_subscription(uuid, text) TO authenticated;
|
||||||
|
|
||||||
|
-- 3) Atualizar triggers para enviar X-Internal-Secret a partir do vault.
|
||||||
|
CREATE OR REPLACE FUNCTION public.trigger_provision_agent()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public', 'extensions'
|
||||||
|
AS $function$
|
||||||
|
DECLARE
|
||||||
|
v_supabase_url text;
|
||||||
|
v_anon_key text;
|
||||||
|
v_internal_secret text;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.status <> 'provisioning' THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF TG_OP = 'UPDATE' AND OLD.status = 'provisioning' THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
SELECT decrypted_secret INTO v_supabase_url
|
||||||
|
FROM vault.decrypted_secrets WHERE name = 'project_url' LIMIT 1;
|
||||||
|
SELECT decrypted_secret INTO v_anon_key
|
||||||
|
FROM vault.decrypted_secrets WHERE name = 'anon_key' LIMIT 1;
|
||||||
|
SELECT decrypted_secret INTO v_internal_secret
|
||||||
|
FROM vault.decrypted_secrets WHERE name = 'internal_function_secret' LIMIT 1;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
v_supabase_url := NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF v_supabase_url IS NULL OR v_anon_key IS NULL THEN
|
||||||
|
RAISE LOG 'trigger_provision_agent: vault não configurado, pulando';
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM net.http_post(
|
||||||
|
url := v_supabase_url || '/functions/v1/provision-agent',
|
||||||
|
headers := jsonb_build_object(
|
||||||
|
'Content-Type', 'application/json',
|
||||||
|
'Authorization', 'Bearer ' || v_anon_key,
|
||||||
|
'X-Internal-Secret', COALESCE(v_internal_secret, '')
|
||||||
|
),
|
||||||
|
body := jsonb_build_object('agent_instance_id', NEW.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.trigger_suspend_or_resume_agent()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public', 'extensions'
|
||||||
|
AS $function$
|
||||||
|
DECLARE
|
||||||
|
v_supabase_url text;
|
||||||
|
v_anon_key text;
|
||||||
|
v_internal_secret text;
|
||||||
|
v_function text;
|
||||||
|
v_agent_id uuid;
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP <> 'UPDATE' THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.status = OLD.status THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.status IN ('active', 'trialing') AND OLD.status NOT IN ('active', 'trialing') THEN
|
||||||
|
v_function := 'resume-agent';
|
||||||
|
ELSIF NEW.status IN ('canceled', 'past_due', 'unpaid', 'paused') AND OLD.status IN ('active', 'trialing') THEN
|
||||||
|
v_function := 'suspend-agent';
|
||||||
|
ELSE
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT id INTO v_agent_id FROM public.agent_instances WHERE user_id = NEW.user_id LIMIT 1;
|
||||||
|
IF v_agent_id IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
SELECT decrypted_secret INTO v_supabase_url
|
||||||
|
FROM vault.decrypted_secrets WHERE name = 'project_url' LIMIT 1;
|
||||||
|
SELECT decrypted_secret INTO v_anon_key
|
||||||
|
FROM vault.decrypted_secrets WHERE name = 'anon_key' LIMIT 1;
|
||||||
|
SELECT decrypted_secret INTO v_internal_secret
|
||||||
|
FROM vault.decrypted_secrets WHERE name = 'internal_function_secret' LIMIT 1;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
v_supabase_url := NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF v_supabase_url IS NULL OR v_anon_key IS NULL THEN
|
||||||
|
RAISE LOG 'trigger_suspend_or_resume_agent: vault não configurado, pulando';
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM net.http_post(
|
||||||
|
url := v_supabase_url || '/functions/v1/' || v_function,
|
||||||
|
headers := jsonb_build_object(
|
||||||
|
'Content-Type', 'application/json',
|
||||||
|
'Authorization', 'Bearer ' || v_anon_key,
|
||||||
|
'X-Internal-Secret', COALESCE(v_internal_secret, '')
|
||||||
|
),
|
||||||
|
body := jsonb_build_object('agent_instance_id', v_agent_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
-- 4) Atualizar cron job keep-alive para enviar X-Internal-Secret
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_anon_key text;
|
||||||
|
v_internal_secret text;
|
||||||
|
v_command text;
|
||||||
|
BEGIN
|
||||||
|
SELECT decrypted_secret INTO v_anon_key
|
||||||
|
FROM vault.decrypted_secrets WHERE name = 'anon_key' LIMIT 1;
|
||||||
|
SELECT decrypted_secret INTO v_internal_secret
|
||||||
|
FROM vault.decrypted_secrets WHERE name = 'internal_function_secret' LIMIT 1;
|
||||||
|
|
||||||
|
IF v_anon_key IS NULL OR v_internal_secret IS NULL THEN
|
||||||
|
RAISE LOG 'keep-alive cron: vault não populado, mantendo schedule atual';
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_command := format($cmd$
|
||||||
|
SELECT net.http_post(
|
||||||
|
url := 'https://smsarmgoirlcedmqvdgc.supabase.co/functions/v1/keep-alive-agents',
|
||||||
|
headers := jsonb_build_object(
|
||||||
|
'Content-Type', 'application/json',
|
||||||
|
'Authorization', 'Bearer %s',
|
||||||
|
'X-Internal-Secret', '%s'
|
||||||
|
),
|
||||||
|
body := '{}'::jsonb
|
||||||
|
);
|
||||||
|
$cmd$, v_anon_key, v_internal_secret);
|
||||||
|
|
||||||
|
PERFORM cron.unschedule('keep-alive-agents-every-4min');
|
||||||
|
PERFORM cron.schedule('keep-alive-agents-every-4min', '*/4 * * * *', v_command);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue