Skip to content

Commit a8c9fc3

Browse files
sametbasbugclaude
andcommitted
Reddedilen ajan eylemlerini denetim izine yaz
Reddedilen bir deneme ajanın ekranında içeriksiz bir 502 olarak görünüyor — Orbit sitenin hata gövdesini taşımıyor. Yani "kim ne denedi" sorusunun cevabı yalnız bizde olabilir. Bugün tek insan Samet olduğu için sorun görünmüyor; site başkalarına açıldığında tek denetim izi bu. Ayrı tablo, `orbit_action_log`a yazılmıyor. O tablonun birincil anahtarı `(subject, idempotency_key)` ve görevi tekrar koruması; reddi oraya yazmak reddedilen denemeyi sonsuza kadar "tekrar" olarak döndürmek, yani ikinci denemeyi hiç çalıştırmamak olurdu. Kapı: YALNIZ imzası doğrulanmış istekler yazılıyor. `/api/orbit-eylem` herkese açık bir adres ve imzasız çöp her istekte bir satır yazdırabilseydi bu tablo bir yazma silahına dönerdi. İmzasız istekler Worker günlüğünde kalıyor. Editoryal karar (409 "zaten yayında", 422 sözleşme) yazılmıyor: o reddetme değil, cevap. Sebebi ajan zaten gövdede görüyor. Girdi saklanmıyor — yayımlanmamış haber metnini ve base64 görseli veritabanında biriktirmek olurdu. 36 vaka; kapı ve editoryal ayrım mutasyonla sınandı. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8d037bb commit a8c9fc3

3 files changed

Lines changed: 145 additions & 9 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
-- Reddedilen ajan eylemleri.
2+
--
3+
-- `orbit_action_log` yalnız BAŞARILI işleri tutuyor ve tutmalı: birincil
4+
-- anahtarı `(subject, idempotency_key)` ve görevi tekrar korumasıdır. Reddi
5+
-- oraya yazmak, reddedilen bir denemeyi sonsuza kadar "tekrar" olarak
6+
-- döndürmek olurdu — ikinci deneme hiç çalışmazdı.
7+
--
8+
-- Bu yüzden ayrı tablo. Sorusu farklı: "kim ne denedi ve neden geçmedi".
9+
--
10+
-- BURAYA YALNIZ İMZASI DOĞRULANMIŞ İSTEKLER YAZILIYOR ve bu sınır önemli.
11+
-- `/api/orbit-eylem` herkese açık bir adres; imzasız çöp gönderen biri her
12+
-- istekte bir satır yazdırabilseydi bu tablo bir yazma silahına dönerdi.
13+
-- İmzasız istekler yalnız Worker günlüğüne düşüyor (observability açık).
14+
-- İmzalı bir istek ise gerçekten Orbit'ten geliyor demektir; oraya ulaşmak
15+
-- için önce Orbit'te kimlik ve açık bir ajan erişimi gerekiyor.
16+
CREATE TABLE orbit_action_denials (
17+
id INTEGER PRIMARY KEY AUTOINCREMENT,
18+
19+
-- Belgedeki insan ve aktör. İmza doğrulandığı için ikisi de güvenilir.
20+
subject TEXT NOT NULL,
21+
actor_subject TEXT NOT NULL,
22+
23+
-- Gövdedeki işlem. Belgedekiyle uyuşmadığı için reddedildiyse ikisi de
24+
-- ilgi çekici, o yüzden ayrı ayrı saklanıyor.
25+
operation_id TEXT,
26+
document_operation TEXT,
27+
28+
status INTEGER NOT NULL,
29+
reason TEXT NOT NULL,
30+
created_at TEXT NOT NULL
31+
);
32+
33+
-- Girdi SAKLANMIYOR. Reddedilen bir istekte ne olduğu değil, kimin neyi
34+
-- denediği önemli; gövdeyi tutmak yayımlanmamış haber metnini ve base64
35+
-- görseli veritabanında biriktirmek olurdu.
36+
37+
CREATE INDEX orbit_action_denials_created_idx ON orbit_action_denials (created_at DESC);
38+
CREATE INDEX orbit_action_denials_actor_idx ON orbit_action_denials (actor_subject, created_at DESC);

‎worker/src/orbit-eylem.ts‎

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ function json(body: unknown, status = 200): Response {
2222
});
2323
}
2424

25-
/* Her reddetme loglanıyor.
25+
/* Her reddetme günlüğe yazılıyor.
2626
*
2727
* Orbit sitenin hata GÖVDESİNİ ajana taşımıyor — bilerek, çünkü içeriğini
2828
* bilmediği bir metni kendi cevabı gibi göstermek olurdu. Doğru karar ama
@@ -33,6 +33,44 @@ function reddet(status: number, mesaj: string): Response {
3333
return json({ error: mesaj }, status);
3434
}
3535

36+
/* İmzası doğrulanmış bir isteğin reddi ayrıca VERİTABANINA yazılıyor.
37+
*
38+
* Sebebi: reddedilen deneme ajanın ekranında içeriksiz bir 502 olarak
39+
* görünüyor, yani "kim denedi" sorusunun cevabı yalnız bizde olabilir. Site
40+
* başkalarına açıldığında bu tek denetim izi.
41+
*
42+
* Kapı `verified`: imzasız istekler buraya HİÇ gelmiyor, yalnız Worker
43+
* günlüğüne düşüyor. `/api/orbit-eylem` herkese açık bir adres ve imzasız çöp
44+
* her istekte bir satır yazdırabilseydi bu bir yazma silahı olurdu.
45+
*
46+
* Yazma başarısız olursa istek reddedilmeye devam ediyor: denetim izi
47+
* tutulamaması güvenlik kararını değiştirmez, sadece kaydı eksiltir. */
48+
async function denetimeYaz(
49+
env: Env,
50+
verified: { subject: string; actorSubject: string; operation: string },
51+
operationId: string,
52+
status: number,
53+
reason: string,
54+
): Promise<void> {
55+
try {
56+
await env.DB.prepare(
57+
`INSERT INTO orbit_action_denials
58+
(subject, actor_subject, operation_id, document_operation, status, reason, created_at)
59+
VALUES (?,?,?,?,?,?,?)`,
60+
).bind(
61+
verified.subject,
62+
verified.actorSubject,
63+
operationId.length > 0 ? operationId : null,
64+
verified.operation,
65+
status,
66+
reason,
67+
new Date().toISOString(),
68+
).run();
69+
} catch (error) {
70+
console.error(`orbit-eylem: reddetme kaydedilemedi: ${String(error)}`);
71+
}
72+
}
73+
3674
async function digest(value: string): Promise<string> {
3775
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
3876
return [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -54,15 +92,20 @@ async function digest(value: string): Promise<string> {
5492
* reddetme olarak çıkıyor. */
5593
const KARAR_DURUMLARI = new Set([400, 404, 409, 413, 422]);
5694

57-
async function sonucaCevir(response: Response): Promise<{ output: Record<string, unknown> } | Response> {
95+
async function sonucaCevir(
96+
response: Response,
97+
reddetVeYaz: (status: number, mesaj: string) => Promise<Response>,
98+
): Promise<{ output: Record<string, unknown> } | Response> {
5899
const govde = await response.json<any>().catch(() => ({}));
59100
if (response.ok) return { output: { uygulandi: true, ...govde } };
60101

102+
/* Editoryal karar denetim izine yazılmıyor: reddedilmedi, cevaplandı.
103+
* Ajan sebebini gövdede görüyor ve "kim denedi" sorusu doğmuyor. */
61104
if (KARAR_DURUMLARI.has(response.status)) {
62105
console.error(`orbit-eylem: yayın kapısı reddetti ${response.status} ${JSON.stringify(govde).slice(0, 300)}`);
63106
return { output: { uygulandi: false, durum: response.status, ...govde } };
64107
}
65-
return reddet(response.status, `yayın hattı reddetti: ${JSON.stringify(govde).slice(0, 300)}`);
108+
return reddetVeYaz(response.status, `yayın hattı reddetti: ${JSON.stringify(govde).slice(0, 300)}`);
66109
}
67110

68111
export async function siteAction(request: Request, env: Env): Promise<Response> {
@@ -99,15 +142,22 @@ export async function siteAction(request: Request, env: Env): Promise<Response>
99142

100143
/* Belgedeki işlem gövdedekiyle aynı olmak ZORUNDA. Aksi halde "pano yaz"
101144
* için alınmış bir belge, gövdesi değiştirilerek "yayımla"ya çevrilirdi. */
145+
/* Buradan sonraki her reddetme denetim izine de yazılıyor: belge doğrulandı,
146+
* yani istek gerçekten Orbit'ten geliyor ve arkasında bir kimlik var. */
147+
const reddetVeYaz = async (status: number, mesaj: string) => {
148+
await denetimeYaz(env, verified, operationId, status, mesaj);
149+
return reddet(status, mesaj);
150+
};
151+
102152
if (verified.operation !== operationId) {
103-
return reddet(403, "belge bu işlem için verilmemiş");
153+
return reddetVeYaz(403, "belge bu işlem için verilmemiş");
104154
}
105155
if (!(OPERATIONS as readonly string[]).includes(operationId)) {
106-
return reddet(404, `bilinmeyen işlem: ${operationId}`);
156+
return reddetVeYaz(404, `bilinmeyen işlem: ${operationId}`);
107157
}
108158

109159
const auth = await authorizeAction(verified, env);
110-
if (!auth.ok) return reddet(auth.status, auth.error);
160+
if (!auth.ok) return reddetVeYaz(auth.status, auth.error);
111161

112162
const inputDigest = await digest(JSON.stringify({ operationId, input }));
113163

@@ -123,7 +173,7 @@ export async function siteAction(request: Request, env: Env): Promise<Response>
123173
* Sessizce ilk cevabı döndürmek, ajanın yaptığını sandığı işin hiç
124174
* yapılmaması olurdu. */
125175
if (gecmis.input_digest !== inputDigest) {
126-
return reddet(409, "aynı Idempotency-Key farklı bir istekle kullanıldı");
176+
return reddetVeYaz(409, "aynı Idempotency-Key farklı bir istekle kullanıldı");
127177
}
128178
return json({ status: "replayed", output: JSON.parse(gecmis.output) });
129179
}
@@ -132,7 +182,7 @@ export async function siteAction(request: Request, env: Env): Promise<Response>
132182
? await writeBriefAs(auth.identity, input, env)
133183
: await publishAs(auth.identity, input, env);
134184

135-
const sonuc = await sonucaCevir(response);
185+
const sonuc = await sonucaCevir(response, reddetVeYaz);
136186
if (sonuc instanceof Response) return sonuc;
137187

138188
/* Kayıt yazmadan önce iş bitmiş oluyor ve bu sıra bilerek: kaydı önce

‎worker/tools/orbit-eylem-tests.mjs‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ async function belge({ claims = {}, key = pair.privateKey } = {}) {
5252
* biriktiriyor ki tekrar kaydının gerçekten yazıldığı görülebilsin. */
5353
function sahteDB({ yayinciSatiri, gecmis = null }) {
5454
const yazilanlar = [];
55+
const reddedilenler = [];
5556
return {
5657
yazilanlar,
58+
reddedilenler,
5759
prepare(sql) {
5860
return {
5961
bind(...args) {
@@ -74,7 +76,11 @@ function sahteDB({ yayinciSatiri, gecmis = null }) {
7476
if (sql.includes("FROM orbit_action_log")) return gecmis;
7577
return null;
7678
},
77-
async run() { yazilanlar.push({ sql, args }); return { success: true }; },
79+
async run() {
80+
if (sql.includes("orbit_action_denials")) reddedilenler.push({ sql, args });
81+
else yazilanlar.push({ sql, args });
82+
return { success: true };
83+
},
7884
};
7985
},
8086
};
@@ -247,6 +253,48 @@ console.log("\n── katalog ──");
247253
true);
248254
}
249255

256+
console.log("\n── denetim izi ──");
257+
{
258+
/* İmzası doğrulanmış bir isteğin reddi kaydedilmeli: ajan içeriksiz bir 502
259+
* görüyor, "kim denedi" sorusunun cevabı yalnız bizde olabilir. */
260+
const db = sahteDB({ yayinciSatiri: null });
261+
await siteAction(istek(await belge(), { operationId: "haber.panoYaz", input: PANO }), { DB: db, ...ORTAM });
262+
check("yetkisiz ajan denetim izine yazılıyor", db.reddedilenler.length, 1);
263+
const [, actor, opId, belgeIslem, status, sebep] = db.reddedilenler[0].args;
264+
check(" aktör kaydediliyor", actor, AJAN);
265+
check(" işlem kaydediliyor", `${opId} · ${belgeIslem}`, "haber.panoYaz · haber.panoYaz");
266+
check(" durum ve sebep kaydediliyor", `${status} ${sebep}`, "403 bu ajan yayıncı listesinde değil");
267+
}
268+
{
269+
const db = sahteDB({ yayinciSatiri: SELENE });
270+
await siteAction(istek(await belge(), { operationId: "haber.yayinla", input: {} }), { DB: db, ...ORTAM });
271+
check("işlem uyuşmazlığı kaydediliyor", db.reddedilenler[0].args[4], 403);
272+
check(" gövdedeki ve belgedeki işlem ayrı tutuluyor",
273+
`${db.reddedilenler[0].args[2]} · ${db.reddedilenler[0].args[3]}`, "haber.yayinla · haber.panoYaz");
274+
}
275+
{
276+
/* İMZASIZ istek denetim izine YAZILMAMALI. `/api/orbit-eylem` herkese açık;
277+
* imzasız çöpün her istekte satır yazdırması bir yazma silahı olurdu. */
278+
const db = sahteDB({ yayinciSatiri: SELENE });
279+
await siteAction(istek(null, { operationId: "haber.panoYaz", input: PANO }), { DB: db, ...ORTAM });
280+
const yabanci = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
281+
await siteAction(istek(await belge({ key: yabanci.privateKey }), { operationId: "haber.panoYaz", input: PANO }), { DB: db, ...ORTAM });
282+
check("imzasız ve sahte imzalı istekler yazılmıyor", db.reddedilenler.length, 0);
283+
}
284+
{
285+
const db = sahteDB({ yayinciSatiri: SELENE, gecmis: { input_digest: "baska-ozet", output: "{}" } });
286+
await siteAction(istek(await belge(), { operationId: "haber.panoYaz", input: PANO }), { DB: db, ...ORTAM });
287+
check("anahtar çakışması kaydediliyor", db.reddedilenler[0].args[4], 409);
288+
}
289+
{
290+
/* Editoryal karar reddetme DEĞİL: cevaplandı, sebebi gövdede. */
291+
const db = sahteDB({ yayinciSatiri: SELENE });
292+
const r = await siteAction(istek(await belge(), { operationId: "haber.panoYaz", input: { board: "dizi değil" } }), { DB: db, ...ORTAM });
293+
const govde = await r.json();
294+
check("editoryal karar 2xx ile dönüyor", `${r.status} ${govde.output.uygulandi}`, "200 false");
295+
check(" ve denetim izine yazılmıyor", db.reddedilenler.length, 0);
296+
}
297+
250298
const failed = results.filter((r) => !r.ok);
251299
console.log(`\n${results.length - failed.length}/${results.length} geçti`);
252300
if (failed.length > 0) process.exit(1);

0 commit comments

Comments
 (0)