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
2 changes: 1 addition & 1 deletion apps/server/src/shared-mailbox-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export class SharedMailboxRuntime implements SharedMailboxAdminPort {
const source = this.opts.directorySources?.[provider];
if (!source) throw new CommandError("unavailable", `directory provider가 구성되지 않았습니다: ${provider}`);
const snapshot = await source.read(tenantId);
const input = await directorySyncInputFromSnapshot(this.opts.db, { tenantId, provider, now: Date.now(), snapshot });
const input = directorySyncInputFromSnapshot({ tenantId, provider, now: Date.now(), snapshot });
await syncDirectorySnapshot(this.opts.db, input);
return { data: { provider, identities: snapshot.identities.length, groups: snapshot.groups.length }, message: "directory snapshot 동기화 완료" };
}
Expand Down
13 changes: 13 additions & 0 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,19 @@ for n in mx.ionosphere.test mta-sts.ionosphere.test admin.ionosphere.test; do
done
```

### 디렉터리 외부 키 재연결 방지

LDAP/AD `directory-identity-link`도 OIDC와 같은 최초 주인 규칙을 쓴다. 외부 키를 처음 연결하면
`identity_claims`에 테넌트·공급자·외부 키 해시·계정 id를 남긴다. 연결을 끊거나 계정을 삭제해도 묘비는
지우지 않으므로, 같은 외부 키는 원래 계정에만 다시 연결할 수 있다. 계정 삭제 뒤에는 원래 계정도 없으므로
새 계정에 재사용할 수 없다. 외부 키 원문(DN 등)은 영구 기록에 남기지 않는다.

이 기능이 생기기 전에 이미 연결된 행도 안전하다. `directory-identity-unlink`와 계정 드레인이 연결 행을
지우는 같은 DB 배치에서 먼저 최초 주인을 기록한다.

외부 키 해시는 원문을 직접 남기지 않는 **가명화**다. LDAP DN처럼 후보가 좁은 값은 DB를 읽을 수 있는 사람이
후보를 해시해 대조할 수 있다. 서버 비밀 HMAC은 키 회전 때 묘비를 무효화하므로 쓰지 않는다.

### 외부 OIDC 로그인 (E3)

관리 콘솔과 셀프서비스에 "IdP로 로그인" 버튼을 얹는다. 기본으로 꺼져 있다.
Expand Down
2 changes: 1 addition & 1 deletion docs/PROTOCOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ SPF+DKIM pass · From 도메인 정렬(SPF 또는 DKIM) · DMARC p=none 이상
| 표면 제한 | `credentials.scopes` — 인증 함수 안의 단일 관문, 모르는 이름은 fail closed | `credentialAllowsSurface` |
| 시도 제한 | 실패만 셈, IP 프리픽스·계정 두 축(모든 메일 프로토콜·웹), 5회 뒤 1초부터 두 배·상한 15분·15분 뒤 초기화(공통 수치). 성공하면 계정 축만 지움. 서버들이 공유 DB로 함께 센다(DB 장애 시 프로세스 카운터) | `core/auth-throttle.ts`, `store/auth-failures.ts` |
| 정지 | `account-suspend` — 새 인증·제출·수신 차단, 데이터 보존, 되돌릴 수 있음 | — |
| 삭제 | `account-delete` — 즉시 비노출, 리퍼가 24시간 안에 메일·자격증명·sieve를 지우고 계정 행까지 없앤다(`ionosphere_account_drain_overdue`). 끝날 때까지 주소 재등록 차단 | SCHEMA §7-7 |
| 삭제 | `account-delete` — 즉시 비노출, 리퍼가 24시간 안에 메일·자격증명·sieve를 지우고 계정 행까지 없앤다(`ionosphere_account_drain_overdue`). 끝날 때까지 주소 재등록 차단. SSO sub·LDAP/AD 외부 키 해시는 최초 주인 묘비로 남긴다 | SCHEMA §7-7 |
| 반영 지연 | 새 인증은 즉시. **JMAP은 인증 캐시로 최대 30초**(외부 토큰은 exp를 넘지 않는다). 제출(SMTP)은 메시지마다 상태를 다시 본다. 열린 IMAP·POP3·ManageSieve 연결은 **다음 명령에서** 계정 상태를 다시 본다(G2). 외부 토큰으로 연 연결은 토큰 exp에 닫힌다(E4) | `jmap-server.ts`, `backend.ts`, `proto-*/server.ts`, `core/auth-expiry.ts` |
| 디렉터리 로그인 | 기본 꺼짐. 로컬 실패 뒤 폴백, 관리자가 연결한 불변 키만. provider별 허용 표면(`surfaces`, 생략하면 전부) — 밖이면 bind 전에 거절 | `shared-mailbox-runtime.ts`, `directory-config.ts` |
| 관리 주체 | 사람: `admin_users`(메일 계정과 별개, TOTP 필수, 서버 세션). 서비스: `api_keys`(read/write/admin). 정적 `rootToken`은 부트스트랩 전용(활성 owner가 생기면 경고) | `store/admin-auth.ts`, `api/server.ts` |
Expand Down
13 changes: 8 additions & 5 deletions docs/SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,9 +403,9 @@ CREATE INDEX ix_auth_failures_last ON auth_failures(last_fail_at);

```sql
CREATE TABLE identity_claims (
realm VARCHAR(16) NOT NULL, -- 'admin'(admin_identities) | 'account'(directory_identities의 oidc: 행)
provider VARCHAR(64) NOT NULL, -- 설정의 공급자 id(issuer를 고정한다). account 영역은 'oidc:<id>'
subject VARCHAR(255) NOT NULL, -- IdP sub
realm VARCHAR(16) NOT NULL, -- 'admin' | 'account'(OIDC) | 'directory'(LDAP/AD)
provider VARCHAR(64) NOT NULL, -- OIDC 공급자 id / directory는 '<tenant id>:<provider>'
subject VARCHAR(255) NOT NULL, -- IdP sub / directory는 외부 키의 'sha256:<hex>'
owner_id VARCHAR(26) NOT NULL, -- 처음 연결한 주체(관리자 또는 메일 계정 id)
claimed_at BIGINT NOT NULL,
PRIMARY KEY (realm, provider, subject)
Expand All @@ -420,8 +420,11 @@ sub를 **처음 연결할 때** 주인을 기록하고 지우지 않는다. 연
- 결정의 "iss+sub"는 공급자 id+sub로 대신한다. 같은 영역(account면 같은 테넌트)에서 같은 issuer를 두 id로 쓰는 설정은
기동이 거부한다(`oidc-config.ts`). ⚠ 공급자 id를 바꾸거나 다른 IdP에 다시 쓰지 말 것.
- account 영역도 테넌트와 무관하게 전역이다. 개인정보가 아니라 불투명 식별자뿐이라 탈퇴 뒤에도 남긴다.
- LDAP/AD 디렉터리 연결(`directory-identity-*`)은 대상이 아니다 — SSO 식별자가 아니고, objectGUID는 IdP가 재활용하지 않는다.
- 029는 이미 있는 연결을 그 주인으로 옮겨 적는다(NOT EXISTS로 멱등).
- LDAP/AD 디렉터리 연결도 realm=`directory`로 같은 표를 쓴다(2026-10-19 결정). 계정 드레인이나 연결 해제 뒤
다음 동기화가 같은 외부 키를 만들더라도 다른 계정에 잇지 못한다. 범위는 테넌트+공급자이며, DN 같은 개인정보가
영구 묘비에 원문을 직접 남기지 않게 외부 키 대신 SHA-256을 저장한다. 이는 가명화라 후보 DN을 아는 사람의 대조까지
막지는 않는다. 이 기능 전의 연결은 해제·드레인할 때 먼저 claim한다.
- 029는 당시 있던 OIDC 연결을 그 주인으로 옮겨 적는다(NOT EXISTS로 멱등).

## 5. DDL — 메일 스토어 코어

Expand Down
45 changes: 19 additions & 26 deletions packages/admin-cmd/src/shared-mailbox.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { CommandError, type Command, type CommandContext } from "./types.ts";
import { ulid } from "@ionosphere/core";
import { BatchConflictError } from "@ionosphere/db";
import { directoryMembershipSource } from "@ionosphere/store";
import { linkDirectoryIdentityWithClaim, unlinkDirectoryIdentityWithClaim } from "@ionosphere/store";

function tenantId(ctx: CommandContext): string {
if (!ctx.tenantId) throw new CommandError("invalid", "tenantId가 필요합니다");
Expand Down Expand Up @@ -51,20 +49,18 @@ export const sharedMailboxCommands: readonly Command[] = [
if (otherLink.rows.length > 0) throw new CommandError("conflict", "이 account는 같은 provider의 다른 identity에 연결되어 있습니다");
const linked = identity.rows[0]?.account_id;
if (linked != null && String(linked) !== args.accountId) throw new CommandError("conflict", "directory identity가 다른 account에 연결되어 있습니다");
const now = Date.now();
try {
await ctx.db.batch([
{ sql: "UPDATE directory_identities SET account_id = ? WHERE tenant_id = ? AND provider = ? AND external_key = ?", params: [args.accountId, scope, args.provider, args.externalKey] },
{ sql: ctx.db.insertIgnore("principals", ["id", "tenant_id", "kind", "account_id", "provider", "external_key", "display_name", "created_at"]), params: [ulid(), scope, 0, args.accountId, args.provider, args.externalKey, identity.rows[0]?.display_name ?? null, now] },
{ sql: "UPDATE principals SET account_id = ?, display_name = ? WHERE tenant_id = ? AND kind = 0 AND provider = ? AND external_key = ?", params: [args.accountId, identity.rows[0]?.display_name ?? null, scope, args.provider, args.externalKey] },
{ sql: "UPDATE accounts SET permissions_version = permissions_version + 1 WHERE id = ? AND tenant_id = ?", params: [args.accountId, scope] },
]);
} catch (error) {
// 사전 조회는 안내용일 뿐 동시 요청의 잠금이 아니다. 최종 승인은 DB UNIQUE 제약이 한다.
if (error instanceof BatchConflictError) {
throw new CommandError("conflict", "이 account는 같은 provider의 다른 identity에 연결되어 있습니다");
}
throw error;
const result = await linkDirectoryIdentityWithClaim(ctx.db, {
tenantId: scope,
provider: args.provider!,
externalKey: args.externalKey!,
accountId: args.accountId!,
displayName: identity.rows[0]?.display_name == null ? null : String(identity.rows[0].display_name),
});
if (result === "retired") {
throw new CommandError("conflict", "이 directory identity는 예전에 다른 account에 연결된 적이 있어 다시 이을 수 없습니다");
}
if (result === "conflict") {
throw new CommandError("conflict", "이 account는 같은 provider의 다른 identity에 연결되어 있거나, identity가 다른 account에 연결되어 있습니다");
}
return { data: { provider: args.provider, externalKey: args.externalKey, accountId: args.accountId }, message: "directory identity 연결 완료 — group membership 반영을 위해 다시 동기화하세요" };
},
Expand All @@ -75,15 +71,12 @@ export const sharedMailboxCommands: readonly Command[] = [
// OIDC 연결 행(`oidc:` 접두)은 OIDC 명령만 다룬다 — 여기서 바꾸면 그 명령의 공급자·충돌 검사를 우회한다(2026-10-18 검토).
if ((args.provider ?? "").startsWith("oidc:")) throw new CommandError("invalid", "oidc: 공급자는 oidc-identity-link/unlink로 다루십시오");
const scope = tenantId(ctx);
const identity = await ctx.db.query({ sql: "SELECT account_id FROM directory_identities WHERE tenant_id = ? AND provider = ? AND external_key = ?", params: [scope, args.provider, args.externalKey] });
const accountId = identity.rows[0]?.account_id == null ? null : String(identity.rows[0]?.account_id);
if (!accountId) throw new CommandError("notFound", "연결된 directory identity를 찾을 수 없습니다");
await ctx.db.batch([
{ sql: "DELETE FROM account_memberships WHERE account_id = ? AND source = ?", params: [accountId, directoryMembershipSource(args.provider!)] },
{ sql: "UPDATE principals SET account_id = NULL WHERE tenant_id = ? AND kind = 0 AND provider = ? AND external_key = ?", params: [scope, args.provider, args.externalKey] },
{ sql: "UPDATE directory_identities SET account_id = NULL WHERE tenant_id = ? AND provider = ? AND external_key = ?", params: [scope, args.provider, args.externalKey] },
{ sql: "UPDATE accounts SET permissions_version = permissions_version + 1 WHERE id = ? AND tenant_id = ?", params: [accountId, scope] },
]);
const result = await unlinkDirectoryIdentityWithClaim(ctx.db, {
tenantId: scope,
provider: args.provider!,
externalKey: args.externalKey!,
});
if (result === "notFound") throw new CommandError("notFound", "연결된 directory identity를 찾을 수 없습니다");
return { data: { provider: args.provider, externalKey: args.externalKey }, message: "directory identity 연결 해제 완료" };
},
},
Expand Down
6 changes: 4 additions & 2 deletions packages/db/src/migrations/029_identity_claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import type { Migration } from "../migrate.ts";
* 판정이라, "묘비를 읽은 뒤 연결을 넣는" 사이에 해제가 끼어 재사용되는 경합이 없다(2026-10-19 리뷰). 원래 주인에게 다시 잇는
* 것은 허용한다(실수로 끊은 것을 되돌릴 수 있어야 한다).
*
* - `realm`: "admin"(관리 콘솔 관리자, `admin_identities`) 또는 "account"(메일 계정, `directory_identities`의 `oidc:` 행).
* - `realm`: "admin"(관리 콘솔 관리자), "account"(메일 계정 OIDC), "directory"(LDAP/AD 외부 키 묘비).
* - `provider`: 설정의 공급자 id다. issuer는 공급자 설정이 고정하고, 같은 영역에서 같은 issuer를 두 id로 쓰는 설정은 기동이
* 거부한다(`oidc-config.ts`). 그래서 결정의 "iss"는 이것으로 대신한다. ⚠ 공급자 id를 바꾸거나 다른 IdP에 다시 쓰지 말 것.
* - account 영역도 테넌트와 무관하게 전역이다 — 공급자의 테넌트를 옮겨도 "iss+sub는 하나"라는 결정을 그대로 지킨다.
* - 개인정보가 아니라 IdP의 불투명 식별자뿐이다 — 탈퇴 뒤에도 남겨 둔다(같은 결정).
* - LDAP/AD 디렉터리 연결(`directory-identity-*`)은 대상이 아니다: SSO 식별자가 아니고, objectGUID는 IdP가 재활용하지 않는다.
* - 2026-10-19부터 LDAP/AD 디렉터리 연결도 같은 표에 realm="directory"로 남긴다. 계정 드레인·연결 해제 뒤
* 다음 동기화가 같은 외부 키를 다른 계정에 잇는 것을 막는다. 외부 키 원문 대신 SHA-256만 저장한다.
* 029 당시 연결은 별도 backfill하지 않는다 — 해제·드레인이 행을 지우는 같은 배치에서 먼저 기록한다.
*
* 이미 있는 연결은 그 주인으로 옮겨 적는다(account 영역은 테넌트가 달라도 같은 sub면 하나만 — 가장 작은 계정 id).
* 마이그레이션은 문장 단위로 다시 돌 수 있으므로 옮겨 적기는 NOT EXISTS로 멱등이다.
Expand Down
24 changes: 21 additions & 3 deletions packages/store/src/account-drain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@
* - 서버 세 대의 리퍼가 같은 계정을 함께 지울 수 있다. 문장이 전부 멱등해 결과는 같다.
*
* 지우지 않는 것:
* - `identity_claims`(SSO sub의 최초 주인) — 같은 sub를 다른 계정에 다시 잇지 못하게 하는 기록이라
* 계정이 사라져도 남겨야 한다(마이그레이션 029). 값은 IdP의 불투명 식별자다.
* - `identity_claims`(SSO sub·디렉터리 외부 키의 최초 주인) — 같은 식별자를 다른 계정에 다시 잇지 못하게 하는
* 묘비라 계정이 사라져도 남긴다(마이그레이션 029). 디렉터리 외부 키는 SHA-256만 적는다.
* - 블롭 파일 — `blob_refs`를 지우면 블롭 GC가 참조 0으로 보고 회수한다. GC가 `off`면 파일은 남는다.
*/
import { ACCOUNT_STATUS, MTA_QUEUE_STATUS, REF_KIND, type DbDriver, type Statement } from "@ionosphere/db";
import { chunk } from "./chunk.ts";
import { directoryClaimStatement } from "./directory-claims.ts";

/** 계정 드레인 마감 — 회원 공통 기준(2026-10-19 P8). 넘기면 지표가 overdue로 센다. */
export const ACCOUNT_DRAIN_DEADLINE_MS = 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -100,7 +102,23 @@ export async function drainAccount(db: DbDriver, accountId: string, opts: { maxP
* 배달 결과를 쓸 곳이 사라진다. 리스는 짧아서 다음 tick이면 풀린다. 남아 있으면 accounts 행을 지우지 않는다.
* 리스가 **만료된** 발송 중 행은 지운다 — 그대로 두면 워커가 회수해 탈퇴한 계정의 메일을 다시 보내려 한다.
*/
const results = await db.batch([...accountStatements(accountId, opts.now ?? Date.now()), finalDeleteStatement(accountId)]);
const { rows: directoryLinks } = await db.query({
sql: "SELECT tenant_id, provider, external_key FROM directory_identities WHERE account_id = ? AND provider NOT LIKE 'oidc:%'",
params: [accountId],
});
const now = opts.now ?? Date.now();
/**
* D1은 배치당 문장 1000개가 상한이다. 묘비를 900개씩 먼저 남긴다 — 계정은 이미 삭제 중이고
* 되살릴 수 없으므로 중간에 죽어도 다음 tick이 같은 INSERT IGNORE를 다시 하며 수렴한다.
*/
for (const part of chunk(directoryLinks, 900)) {
await db.batch(
part.map((row) =>
directoryClaimStatement(db, String(row.tenant_id), String(row.provider), String(row.external_key), accountId, now),
),
);
}
const results = await db.batch([...accountStatements(accountId, now), finalDeleteStatement(accountId)]);
return { messages, done: (results[results.length - 1]?.changes ?? 0) > 0 };
}

Expand Down
Loading
Loading