Skip to content

Commit d93bd1a

Browse files
sametbasbugclaude
andcommitted
Record who signs in, and how to reach them
Orbit is opening to the public, and two questions had no answer. When a lawful request asks who published an unlawful post, the only handle we had was a GitHub account — resolvable, but only through a US company. And when a security incident needs disclosing, there was no way to reach anyone at all. Sign-in traces answer the first. Every human sign-in now records IP, ASN and organization, country and time, written inside the same batch as the login so the two cannot disagree. The scope is deliberately narrow: the agent's API calls are not traced, because the address seen there belongs to the datacenter the agent runs in, not to the person responsible for it. Browsing is not traced either — only signing in with your own browser. Every sign-in is recorded, not just registration, because Cloudflare does not expose the client source port, so a single observation behind CGNAT may not narrow to a subscriber and repetition across networks is the only answer available. Using a VPN is recorded, never blocked: blocking datacenter ranges would turn away iCloud Private Relay and ordinary corporate networks while a residential proxy walks straight through. Retention is one year, and cleanup enforces it. user:email answers the second. GitHub's verified primary address is stored, with a fallback to any verified address when the primary is mid-verification — losing the only channel for a security notice is worse than an imperfect choice. Unverified addresses are never stored: writing to an unclaimed inbox means writing to a stranger. Neither is the @users.noreply.github.com address that appears verified and primary for privacy-enabled accounts — GitHub does not deliver there, so storing it would leave us believing we can reach someone we cannot, while the bounces degrade delivery to everyone we can. The privacy notice moves with the code, and its claims are locked to their sources: the scope, the traced columns, the one-year retention, the promise that agent calls are not traced and VPNs are not blocked, and the promise that the address is used for service notices only and never for marketing. Reading the deletion path to write that notice also turned up a sentence that was already false — records are soft-deleted, so "the row is genuinely deleted" was wrong. It now says what the code does: content leaves every surface, the record stays for legal answer. The traces have no HTTP surface on purpose. An endpoint serving IP addresses is a surface to leak them from; a lawful request is rare enough to answer by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent fe6c6ee commit d93bd1a

12 files changed

Lines changed: 577 additions & 26 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
-- Hesabı gerçek bir aboneye bağlayabilmek için giriş anlarının bağlantı izi.
2+
--
3+
-- Neden yalnız giriş anları: Orbit'te içeriği ajan yayımlıyor ve ajan bunu
4+
-- tarayıcıdan değil API'den yapıyor. Yayın anında görülecek IP, ajanın
5+
-- çalıştığı veri merkezinin IP'sidir — sorumlu insanın değil. İnsanın kendi
6+
-- bağlantısı yalnız panele girdiğinde görünüyor, o yüzden iz burada tutuluyor.
7+
-- Ajanların API istekleri bilerek kapsam dışı.
8+
--
9+
-- Neden her giriş, yalnız kayıt değil: CGNAT arkasındaki tek bir gözlem
10+
-- aboneyi daraltmaya yetmeyebilir ve Cloudflare bize kaynak portu vermiyor,
11+
-- yani operatörün kullanacağı IP+port+zaman üçlüsünü üretemiyoruz. Buna
12+
-- verilen cevap çokluk: aylar boyunca farklı ağlardan biriken gözlemlerin
13+
-- hepsinin CGNAT arkasından gelmesi düşük ihtimal.
14+
--
15+
-- asn ve asn_organization ücretsiz geliyor ve iki işe yarıyor: talebi doğru
16+
-- operatöre yönlendirmek, ve bir hesabın bütün girişlerinin VPN veya veri
17+
-- merkezi üzerinden geldiğini görmek. Bu tek başına bir suç değil, bir
18+
-- sinyal — VPN kullanımı engellenmiyor, yalnız kaydediliyor.
19+
--
20+
-- ip nullable, çünkü yerel ve test ortamında cf-connecting-ip yok. Girişin
21+
-- kendisi ize bağlı değil: iz alınamadığında da giriş çalışmalı.
22+
23+
CREATE TABLE account_sign_in_events (
24+
id TEXT PRIMARY KEY,
25+
account_id TEXT NOT NULL REFERENCES accounts(id),
26+
event_type TEXT NOT NULL CHECK (event_type IN ('registration', 'sign_in')),
27+
ip TEXT,
28+
asn INTEGER,
29+
asn_organization TEXT,
30+
country TEXT,
31+
created_at INTEGER NOT NULL
32+
);
33+
34+
-- Bir hesabın giriş geçmişini zaman sırasına göre okumak: hukuki talebin
35+
-- cevabı da, "bu hesap hep VPN'den mi giriyor" sorusunun cevabı da bu sorgu.
36+
CREATE INDEX account_sign_in_events_account_idx
37+
ON account_sign_in_events (account_id, created_at DESC);
38+
39+
-- Saklama süresi taraması bu indeksten yürüyor; olmazsa temizlik her gece
40+
-- tabloyu baştan sona okur.
41+
CREATE INDEX account_sign_in_events_retention_idx
42+
ON account_sign_in_events (created_at);
43+
44+
-- GitHub'ın doğruladığı birincil e-posta adresi. Yalnız hizmet bildirimi
45+
-- için: hesap, güvenlik, moderasyon, yasal bildirim ve platform duyurusu.
46+
-- Tanıtım ve pazarlama için kullanılmıyor — o ayrı bir rıza rejimi (İYS)
47+
-- ve bu sütun oraya kapı değil.
48+
--
49+
-- Nullable: kullanıcı izni vermezse veya GitHub doğrulanmış adres
50+
-- döndürmezse giriş yine de tamamlanmalı. Adres bir kolaylık, kimlik değil;
51+
-- kimliği taşıyan alan provider_user_id.
52+
ALTER TABLE auth_identities ADD COLUMN provider_email_snapshot TEXT;

‎scripts/orbit-site-tests.mjs‎

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,12 +191,63 @@ for (const [label, html] of [['Ana sayfa', homeHtml], ['Hakkında', fs.readFileS
191191
if (legalHtml.gizlilik) {
192192
const githubSource = fs.readFileSync(path.join(ROOT, 'src', 'server', 'identity', 'github.ts'), 'utf8');
193193
check(
194-
githubSource.includes("url.searchParams.set('scope', 'read:user')"),
195-
'GitHub izin kapsamı değişmiş. Gizlilik metni read:user diyor; kapsam genişlediyse metin artık doğru değil.',
194+
githubSource.includes("url.searchParams.set('scope', 'read:user user:email')"),
195+
'GitHub izin kapsamı değişmiş. Gizlilik metni read:user ve user:email sayıyor; kapsam değiştiyse metin artık doğru değil.',
196+
);
197+
for (const scope of ['read:user', 'user:email']) {
198+
check(
199+
legalHtml.gizlilik.includes(scope),
200+
`Gizlilik metni ${scope} iznini saymıyor.`,
201+
);
202+
}
203+
/* E-postanın ne için istendiği metinde yazılı olmalı ve orada kalmalı.
204+
* "Madem adresler elimizde" diye başlayan cümle çok kolay kuruluyor;
205+
* tanıtım göndermek İYS'ye tabi ayrı bir rıza rejimi ve oraya kazara
206+
* girilmemeli. Kod tarafındaki karşılığı da göç dosyasında yazılı. */
207+
check(
208+
legalHtml.gizlilik.includes('yalnız hizmet bildirimi için kullanılır'),
209+
'Gizlilik metni e-postanın ne için kullanıldığını söylemiyor.',
210+
);
211+
check(
212+
legalHtml.gizlilik.includes('Tanıtım, pazarlama veya bülten gönderilmez'),
213+
'Gizlilik metni tanıtım gönderilmeyeceği sözünü taşımıyor.',
214+
);
215+
/* Giriş izi: hangi alanların toplandığı, neyin toplanmadığı ve süresi.
216+
* Üçü de metinde yazılı, üçü de kodda ölçülebilir. */
217+
const signInMigration = fs.readFileSync(
218+
path.join(ROOT, 'migrations', '0029_sign_in_traces_identify_the_human.sql'),
219+
'utf8',
220+
);
221+
for (const column of ['ip', 'asn', 'asn_organization', 'country']) {
222+
check(
223+
new RegExp(`^\\s+${column}\\b`, 'mu').test(signInMigration),
224+
`Giriş izi tablosunda ${column} alanı yok; gizlilik metni onu sayıyor.`,
225+
);
226+
}
227+
check(
228+
fs.readFileSync(path.join(ROOT, 'src', 'server', 'identity', 'constants.ts'), 'utf8')
229+
.includes('SIGN_IN_EVENT_RETENTION_MS = 365 * 24 * 60 * 60 * 1000'),
230+
'Giriş izinin saklama süresi değişmiş; gizlilik metni bir yıl diyor.',
231+
);
232+
check(
233+
legalHtml.gizlilik.includes('<strong>bir yıl</strong>'),
234+
'Gizlilik metni giriş izinin ne kadar saklandığını söylemiyor.',
235+
);
236+
/* Kapsamın darlığı ürünün sözü: ajanın API isteği kaydedilmiyor ve VPN
237+
* engellenmiyor. İkisi de metinde yazılı olmalı, çünkü ikisi de
238+
* kullanıcının bilmeye hakkı olduğu sınırlar. */
239+
check(
240+
legalHtml.gizlilik.includes('Ajanının API istekleri kaydedilmez'),
241+
'Gizlilik metni ajanın isteklerinin kaydedilmediğini söylemiyor.',
242+
);
243+
check(
244+
legalHtml.gizlilik.includes('VPN kullanmak <strong>engellenmez</strong>'),
245+
'Gizlilik metni VPN kullanımının engellenmediğini söylemiyor.',
196246
);
197247
check(
198-
legalHtml.gizlilik.includes('read:user'),
199-
'Gizlilik metni GitHub izin kapsamını söylemiyor.',
248+
fs.readFileSync(path.join(ROOT, 'src', 'server', 'identity', 'connection.ts'), 'utf8')
249+
.includes("request.headers.get('cf-connecting-ip')"),
250+
'Bağlantı izi IP kaynağını değiştirmiş; x-forwarded-for istemcinin uydurabileceği bir başlık.',
200251
);
201252
const identityConstants = fs.readFileSync(path.join(ROOT, 'src', 'server', 'identity', 'constants.ts'), 'utf8');
202253
for (const cookie of ['__Host-orbit_session', '__Host-orbit_csrf', '__Host-orbit_oauth']) {

‎scripts/orbit-slice1-test-worker.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,21 @@ const PROFILES = {
3030
login: 'sametbasbug',
3131
name: 'Samet Başbuğ',
3232
avatar_url: 'https://example.test/owner.png',
33+
/* Birincil olmayan bir doğrulanmış adres de var: seçim "primary ve
34+
* verified" olmalı, listedeki ilk doğrulanmış adres değil. */
35+
emails: [
36+
{ email: 'ikincil@example.test', primary: false, verified: true },
37+
{ email: 'birincil@example.test', primary: true, verified: true },
38+
],
3339
},
3440
selene: {
3541
id: 200000001,
3642
login: 'selene-owner',
3743
name: 'Selene Owner',
3844
avatar_url: 'https://example.test/selene.png',
45+
/* Doğrulanmamış adres saklanmamalı: doğrulanmamış bir kutuya bildirim
46+
* göndermek, başkasının kutusuna yazmak riskini taşıyor. */
47+
emails: [{ email: 'dogrulanmamis@example.test', primary: true, verified: false }],
3948
},
4049
mismatch: {
4150
id: 200000002,
@@ -59,6 +68,37 @@ const PROFILES = {
5968
name: 'Yeni Kullanıcı',
6069
avatar_url: 'https://example.test/rename.png',
6170
},
71+
/* Giriş izi ve e-posta testlerinin kendi hesapları. Ayrı durmalarının
72+
* sebebi, izleri sayan bir testin başka testlerin girişleriyle
73+
* karışmaması: paylaşılan bir hesapta satır saymak kırılgan olurdu. */
74+
traced: {
75+
id: 200000004,
76+
login: 'izli-kullanici',
77+
name: 'İzli Kullanıcı',
78+
avatar_url: null,
79+
emails: [{ email: 'izli@example.test', primary: true, verified: true }],
80+
},
81+
tracedUnverified: {
82+
id: 200000005,
83+
login: 'dogrulanmamis-kullanici',
84+
name: 'Doğrulanmamış Kullanıcı',
85+
avatar_url: null,
86+
emails: [{ email: 'olmaz@example.test', primary: true, verified: false }],
87+
},
88+
/* "E-posta adresimi gizli tut" açık olan kullanıcının hâli: noreply
89+
* adresi hem birincil hem doğrulanmış görünüyor. GitHub o adrese posta
90+
* teslim etmediği için atlanmalı ve gerçek doğrulanmış adrese
91+
* düşülmeli. */
92+
tracedNoreply: {
93+
id: 200000006,
94+
login: 'gizli-kullanici',
95+
name: 'Gizli Kullanıcı',
96+
avatar_url: null,
97+
emails: [
98+
{ email: 'gizli-kullanici@users.noreply.github.com', primary: true, verified: true },
99+
{ email: 'gercek@example.test', primary: false, verified: true },
100+
],
101+
},
62102
} as const;
63103

64104
class MemoryR2 implements R2BucketLike {
@@ -136,6 +176,17 @@ async function mockGithubFetch(input: RequestInfo | URL, init?: RequestInit): Pr
136176
const profile = profileForToken(new Headers(init?.headers).get('authorization'));
137177
return profile ? Response.json(profile) : Response.json({ message: 'Bad credentials' }, { status: 401 });
138178
}
179+
/* Doğrulanmış birincil adres yalnız burada; /user'ın email alanı
180+
* kullanıcının herkese açık adresini döndürür ve çoğu kişide boştur.
181+
* Adres tanımlanmamış profillerde uç 404 dönüyor — kullanıcının
182+
* user:email iznini vermediği hâlin karşılığı bu. */
183+
if (url.href === 'https://api.github.com/user/emails') {
184+
const profile = profileForToken(new Headers(init?.headers).get('authorization')) as
185+
{ emails?: ReadonlyArray<{ email: string; primary: boolean; verified: boolean }> } | null;
186+
if (!profile) return Response.json({ message: 'Bad credentials' }, { status: 401 });
187+
if (!profile.emails) return Response.json({ message: 'Not Found' }, { status: 404 });
188+
return Response.json(profile.emails);
189+
}
139190
const loginMatch = /^\/users\/([^/]+)$/u.exec(url.pathname);
140191
if (url.origin === 'https://api.github.com' && loginMatch) {
141192
const login = decodeURIComponent(loginMatch[1]).toLowerCase();

0 commit comments

Comments
 (0)