Ctrl K
ComunidadGuías…

server/src/access.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { all, get, newId, now, run, tx } from './db.ts';
import { forbidden, notFound } from './http.ts';
import { config } from './config.ts';

export type User = {
  id: string;
  email: string;
  name: string;
  avatar_url: string;
  google_sub: string | null;
  is_admin: number;
  disabled: number;
  public_key: string | null;
  key_bundle: string | null;
  key_proof: string | null;
  keys_created_at: number | null;
  totp_secret: string | null;
  totp_enabled_at: number | null;
  totp_last_step: number;
  password_hash: string | null;
  password_changed_at: number | null;
};

export type TeamRole = 'owner' | 'admin' | 'member';
export type Perm = 'edit' | 'view';

export type VaultRow = {
  id: string;
  kind: 'personal' | 'team';
  team_id: string | null;
  owner_id: string | null;
  name: string;
  is_default: number;
  created_at: number;
};

export function teamRole(userId: string, teamId: string): TeamRole | null {
  return get<{ role: TeamRole }>('SELECT role FROM team_members WHERE team_id = ? AND user_id = ?', teamId, userId)?.role ?? null;
}

export function isTeamManager(role: TeamRole | null) {
  return role === 'owner' || role === 'admin';
}

export function requireTeamRole(user: User, teamId: string, manager: boolean): TeamRole {
  const role = teamRole(user.id, teamId);
  if (!role) throw notFound('팀을 찾을 수 없습니다');
  if (manager && !isTeamManager(role)) throw forbidden('팀 관리자만 할 수 있습니다');
  return role;
}

// 개인 볼트: 주인만 edit. 팀 볼트: owner/admin 은 모든 볼트 edit, member 는 vault_members 에 있는 볼트만.
export function vaultAccess(user: User, vaultId: string): { vault: VaultRow; perm: Perm } | null {
  const vault = get<VaultRow>('SELECT * FROM vaults WHERE id = ?', vaultId);
  if (!vault) return null;
  if (vault.kind === 'personal') return vault.owner_id === user.id ? { vault, perm: 'edit' } : null;
  const role = teamRole(user.id, vault.team_id!);
  if (!role) return null;
  if (isTeamManager(role)) return { vault, perm: 'edit' };
  const m = get<{ permission: Perm }>('SELECT permission FROM vault_members WHERE vault_id = ? AND user_id = ?', vault.id, user.id);
  return m ? { vault, perm: m.permission } : null;
}

export function requireVault(user: User, vaultId: string, need: Perm): VaultRow {
  const access = vaultAccess(user, vaultId);
  if (!access) throw notFound('볼트를 찾을 수 없습니다');
  if (need === 'edit' && access.perm !== 'edit') throw forbidden('이 볼트는 보기 권한만 있습니다');
  return access.vault;
}

export type VaultSummary = {
  id: string;
  kind: 'personal' | 'team';
  name: string;
  teamId: string | null;
  teamName: string | null;
  perm: Perm;
  isDefault: boolean;
  // 내 공개키로 봉한 볼트 키(없으면 아직 공유받지 못함), 이 볼트 키를 가진 사람이 한 명이라도 있는지
  wrappedKey: string | null;
  keyed: boolean;
  // 그 볼트 키를 봉한 사람(서버 기록). 화면은 봉한 공개키가 정말 이 사람 것인지 암호문으로 확인한다
  wrappedBy: { userId: string; name: string; publicKey: string | null } | null;
};

function keyInfo(vaultId: string, userId: string) {
  const mine = get<{ wrapped: string; wrapped_by: string | null; by_name: string | null; by_key: string | null }>(
    'SELECT vk.wrapped, vk.wrapped_by, u.name AS by_name, u.public_key AS by_key FROM vault_keys vk LEFT JOIN users u ON u.id = vk.wrapped_by WHERE vk.vault_id = ? AND vk.user_id = ?',
    vaultId,
    userId,
  );
  const any = mine ? true : Boolean(get('SELECT 1 AS x FROM vault_keys WHERE vault_id = ? LIMIT 1', vaultId));
  return {
    wrappedKey: mine?.wrapped ?? null,
    keyed: any,
    wrappedBy: mine?.wrapped_by ? { userId: mine.wrapped_by, name: mine.by_name ?? '', publicKey: mine.by_key } : null,
  };
}

export function listVaults(user: User): VaultSummary[] {
  const personal = all<VaultRow>("SELECT * FROM vaults WHERE kind = 'personal' AND owner_id = ?", user.id).map(
    (v): VaultSummary => ({ id: v.id, kind: 'personal', name: v.name, teamId: null, teamName: null, perm: 'edit', isDefault: true, ...keyInfo(v.id, user.id) }),
  );
  const team = all<VaultRow & { team_name: string; role: TeamRole; permission: Perm | null }>(
    `SELECT v.*, t.name AS team_name, tm.role, vm.permission
       FROM team_members tm
       JOIN teams t ON t.id = tm.team_id
       JOIN vaults v ON v.team_id = tm.team_id
       LEFT JOIN vault_members vm ON vm.vault_id = v.id AND vm.user_id = tm.user_id
      WHERE tm.user_id = ?
      ORDER BY t.name, v.is_default DESC, v.name`,
    user.id,
  )
    .filter((v) => isTeamManager(v.role) || v.permission)
    .map(
      (v): VaultSummary => ({
        id: v.id,
        kind: 'team',
        name: v.name,
        teamId: v.team_id,
        teamName: v.team_name,
        perm: isTeamManager(v.role) ? 'edit' : v.permission!,
        isDefault: Boolean(v.is_default),
        ...keyInfo(v.id, user.id),
      }),
    );
  return [...personal, ...team];
}

export function createVault(opts: { kind: 'personal'; ownerId: string; name: string } | { kind: 'team'; teamId: string; name: string; isDefault: boolean }) {
  const id = newId();
  run(
    "INSERT INTO vaults (id, kind, team_id, owner_id, name, is_default, dek, created_at) VALUES (?, ?, ?, ?, ?, ?, '', ?)",
    id,
    opts.kind,
    opts.kind === 'team' ? opts.teamId : null,
    opts.kind === 'personal' ? opts.ownerId : null,
    opts.name,
    opts.kind === 'personal' ? true : opts.isDefault,
    now(),
  );
  return id;
}

export function ensurePersonalVault(userId: string) {
  const v = get<{ id: string }>("SELECT id FROM vaults WHERE kind = 'personal' AND owner_id = ?", userId);
  return v?.id ?? createVault({ kind: 'personal', ownerId: userId, name: 'Personal' });
}

// 팀에 들어오면 기본 팀 볼트를 볼 수 있게 한다(member 는 view, owner/admin 은 원래 전부 edit)
export function addTeamMember(teamId: string, userId: string, role: TeamRole) {
  tx(() => {
    run('INSERT INTO team_members (team_id, user_id, role, joined_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING', teamId, userId, role, now());
    const def = get<{ id: string }>("SELECT id FROM vaults WHERE team_id = ? AND is_default = 1", teamId);
    if (def && role === 'member') {
      run("INSERT INTO vault_members (vault_id, user_id, permission) VALUES (?, ?, 'view') ON CONFLICT DO NOTHING", def.id, userId);
    }
  });
}

export function hasAnyTeam(userId: string) {
  return Boolean(get('SELECT 1 AS x FROM team_members WHERE user_id = ? LIMIT 1', userId));
}

export function hasPendingInvite(email: string) {
  return Boolean(get('SELECT 1 AS x FROM invites WHERE email = ? LIMIT 1', email.toLowerCase()));
}

// 게이트웨이를 쓸 수 있는 사람: 막히지 않았고, 서버 관리자이거나, 누구나 가입하는 서버이거나, 팀이 하나라도 있거나,
// 수락을 기다리는 초대가 있는 사람 (초대는 받은 사람이 로그인해서 수락해야 들어간다)
export function canUseGateway(user: { id: string; is_admin: number; disabled: number; email?: string }) {
  return !user.disabled && (Boolean(user.is_admin) || config.openSignup || hasAnyTeam(user.id) || Boolean(user.email && hasPendingInvite(user.email)));
}

// 내게 온, 아직 수락하지 않은 팀 초대
export function pendingInvites(user: { email: string; google_sub: string | null; password_hash: string | null }) {
  return all<{ id: string; teamId: string; teamName: string; role: TeamRole; invitedBy: string | null; createdAt: number }>(
    `SELECT i.id, i.team_id AS teamId, t.name AS teamName, i.role, u.name AS invitedBy, i.created_at AS createdAt
       FROM invites i JOIN teams t ON t.id = i.team_id LEFT JOIN users u ON u.id = i.invited_by
      WHERE i.email = ? ORDER BY i.created_at`,
    user.email.toLowerCase(),
  ).map((i) => ({ ...i, needsCode: needsInviteCode(user) }));
}

// 아이디·비밀번호로 만든 계정(Google 이 확인한 이메일이 아님)은 초대를 수락할 때 초대 코드도 내야 한다 —
// 이메일만 같으면 남이 먼저 만든 계정이 그 사람 앞으로 온 초대를 가져가게 된다 (보안 점검 H-1)
export const needsInviteCode = (user: { google_sub: string | null; password_hash: string | null }) => !user.google_sub && Boolean(user.password_hash);

// 팀을 만들 수 있는 사람: 서버 관리자, 또는 누구나 가입하는 서버의 모든 사람
export const canCreateTeams = (user: { is_admin: number }) => Boolean(user.is_admin) || config.openSignup;

// 볼트를 더 볼 수 없게 된 사람의 볼트 키를 지운다 (팀에서 나감·역할 내림·볼트 권한 해제)
export function pruneVaultKeys() {
  run(
    `DELETE FROM vault_keys WHERE rowid IN (
       SELECT vk.rowid FROM vault_keys vk JOIN vaults v ON v.id = vk.vault_id
        WHERE (v.kind = 'personal' AND v.owner_id <> vk.user_id)
           OR (v.kind = 'team' AND NOT EXISTS (
                SELECT 1 FROM team_members tm
                 WHERE tm.team_id = v.team_id AND tm.user_id = vk.user_id
                   AND (tm.role IN ('owner', 'admin')
                        OR EXISTS (SELECT 1 FROM vault_members vm WHERE vm.vault_id = v.id AND vm.user_id = vk.user_id)))))`,
  );
}