@athsra/cli 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -2
- package/src/commands/admin.ts +11 -33
- package/src/commands/adopt.ts +1 -5
- package/src/commands/dr.ts +217 -0
- package/src/commands/login.ts +223 -179
- package/src/commands/manifest.ts +3 -10
- package/src/commands/mcp.ts +69 -485
- package/src/commands/new-phrase.ts +1 -1
- package/src/commands/org.ts +363 -0
- package/src/commands/rotate-master.ts +26 -7
- package/src/commands/run.ts +2 -1
- package/src/commands/service-token.ts +17 -6
- package/src/index.ts +7 -0
- package/src/lib/auth-context.ts +77 -18
- package/src/lib/client.ts +396 -31
- package/src/lib/colors.ts +17 -0
- package/src/lib/config.ts +6 -0
- package/src/lib/env-format.ts +5 -53
- package/src/lib/envelope.ts +89 -3
- package/src/lib/identity-key.ts +59 -0
- package/src/lib/keyring.ts +26 -0
- package/src/lib/mcp-tools/args.ts +60 -0
- package/src/lib/mcp-tools/defs.ts +179 -0
- package/src/lib/mcp-tools/read.ts +156 -0
- package/src/lib/mcp-tools/result.ts +46 -0
- package/src/lib/mcp-tools/write.ts +127 -0
- package/src/lib/oidc-flow.ts +200 -0
- package/src/lib/org-rewrap.ts +230 -0
- package/src/lib/secrets-manifest.ts +1 -4
- package/src/lib/wrangler-scan.ts +2 -8
- package/src/lib/bip39.ts +0 -45
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* org.ts — Phase 4 Slice 6a org 멀티테넌시 CLI (Slack/GitHub-org 식 "현재 org" 모델).
|
|
3
|
+
*
|
|
4
|
+
* athsra org create <name> — company org 생성 (생성자 = owner)
|
|
5
|
+
* athsra org ls — 내가 멤버인 org 목록 (* = 현재)
|
|
6
|
+
* athsra org members — 현재 org 멤버
|
|
7
|
+
* athsra org invite <identifier> [--role=R] — 현재 org 에 멤버 초대 (member|admin, JIT pending)
|
|
8
|
+
* athsra org remove <user_id> — 현재 org 에서 멤버 제거
|
|
9
|
+
* athsra org use <slug|id> — org 컨텍스트 전환 (token re-mint + config 기록)
|
|
10
|
+
* athsra org leave — 현재 org 탈퇴
|
|
11
|
+
*
|
|
12
|
+
* 관리(invite/remove)는 그 org 의 owner/admin role. switch 후 그 org 컨텍스트에서 동작한다.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { loadAuthContext, type UserAuthContext } from '../lib/auth-context.ts';
|
|
16
|
+
import { saveConfig } from '../lib/config.ts';
|
|
17
|
+
import { setToken } from '../lib/keyring.ts';
|
|
18
|
+
import { grantOrgAccess, type RevokeRotateResult, rotateAfterRemoval } from '../lib/org-rewrap.ts';
|
|
19
|
+
|
|
20
|
+
const USAGE = [
|
|
21
|
+
'usage:',
|
|
22
|
+
' athsra org create <name> — company org 생성 (owner)',
|
|
23
|
+
' athsra org ls — 내가 멤버인 org 목록 (* = 현재)',
|
|
24
|
+
' athsra org members — 현재 org 멤버',
|
|
25
|
+
' athsra org invite <identifier> [--role=member|admin] — 멤버 초대 (JIT pending)',
|
|
26
|
+
' athsra org remove <user_id> — 멤버 제거 (owner 면 DEK 자동 회전)',
|
|
27
|
+
' athsra org rotate-after-removal <user_id> — 제거 후 DEK 회전 재개 (부분 실패 복구, owner)',
|
|
28
|
+
' athsra org grant-access <identifier|id> [--replace] — 멤버에게 공유 시크릿 re-wrap (--replace=key reset 복구)',
|
|
29
|
+
' athsra org use <slug|id> — org 컨텍스트 전환',
|
|
30
|
+
' athsra org leave — 현재 org 탈퇴',
|
|
31
|
+
].join('\n');
|
|
32
|
+
|
|
33
|
+
async function userCtx(): Promise<UserAuthContext | null> {
|
|
34
|
+
const ctx = await loadAuthContext();
|
|
35
|
+
if (!ctx) return null;
|
|
36
|
+
if (ctx.kind !== 'user') {
|
|
37
|
+
console.error('org 명령은 user token (master pw) 가 필요합니다. service token 거부.');
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return ctx;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 현재 org 에서 caller 의 role (DEK 회전은 owner 만 — master 재wrap 필요). */
|
|
44
|
+
async function currentOrgRole(ctx: UserAuthContext): Promise<'owner' | 'admin' | 'member' | null> {
|
|
45
|
+
const { orgs } = await ctx.client.listOrgs();
|
|
46
|
+
return orgs.find((o) => o.is_current)?.role ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** DEK 회전 스윕 결과 출력 (removeCmd 자동 회전 + rotate-after-removal 복구 공용). */
|
|
50
|
+
function reportRotation(res: RevokeRotateResult): void {
|
|
51
|
+
const parts = [`rotated ${res.rotated.length}`, `skipped ${res.skipped.length}`];
|
|
52
|
+
if (res.legacyV1.length) parts.push(`v1 ${res.legacyV1.length}`);
|
|
53
|
+
if (res.failed.length) parts.push(`failed ${res.failed.length}`);
|
|
54
|
+
console.log(` DEK 회전 — ${parts.join(' · ')}`);
|
|
55
|
+
if (res.rotated.length) {
|
|
56
|
+
console.log(` 회전: ${res.rotated.join(', ')}`);
|
|
57
|
+
console.log(
|
|
58
|
+
' ⓘ 제거 멤버가 이미 본 평문 값은 무효화되지 않습니다 — 민감 시크릿은 값 자체를 별도 로테이션 권고.',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
for (const r of res.reissue) {
|
|
62
|
+
console.log(
|
|
63
|
+
` ⚠ ${r.project}: service token 재발급 필요 (${r.serviceIds.join(', ')}) — DEK 회전으로 무효화됨`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (res.legacyV1.length) {
|
|
67
|
+
console.log(` v1 envelope(회전 불가 — recipient 모델 없음): ${res.legacyV1.join(', ')}`);
|
|
68
|
+
}
|
|
69
|
+
for (const f of res.failed) console.error(` ✗ ${f.project}: ${f.error}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function createOrgCmd(args: string[]): Promise<number> {
|
|
73
|
+
const name = args
|
|
74
|
+
.filter((a) => !a.startsWith('-'))
|
|
75
|
+
.join(' ')
|
|
76
|
+
.trim();
|
|
77
|
+
if (!name) {
|
|
78
|
+
console.error('usage: athsra org create <name>');
|
|
79
|
+
return 2;
|
|
80
|
+
}
|
|
81
|
+
const ctx = await userCtx();
|
|
82
|
+
if (!ctx) return 1;
|
|
83
|
+
try {
|
|
84
|
+
const res = await ctx.client.createOrg(name);
|
|
85
|
+
console.log(`✓ org created: #${res.id} ${res.slug} (${res.type}) — 너는 owner`);
|
|
86
|
+
console.log(` 전환: athsra org use ${res.slug}`);
|
|
87
|
+
return 0;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(`✗ create failed: ${(err as Error).message}`);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function lsOrgsCmd(): Promise<number> {
|
|
95
|
+
const ctx = await userCtx();
|
|
96
|
+
if (!ctx) return 1;
|
|
97
|
+
const { orgs } = await ctx.client.listOrgs();
|
|
98
|
+
if (orgs.length === 0) {
|
|
99
|
+
console.log('(no orgs)');
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
for (const o of orgs) {
|
|
103
|
+
const cur = o.is_current ? '*' : ' ';
|
|
104
|
+
const pend = o.status === 'pending' ? ' (pending invite — `org use` 로 수락)' : '';
|
|
105
|
+
console.log(`${cur} ${o.slug} [${o.role}] #${o.id}${pend}`);
|
|
106
|
+
}
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function membersCmd(): Promise<number> {
|
|
111
|
+
const ctx = await userCtx();
|
|
112
|
+
if (!ctx) return 1;
|
|
113
|
+
try {
|
|
114
|
+
const { members } = await ctx.client.listOrgMembers();
|
|
115
|
+
if (members.length === 0) {
|
|
116
|
+
console.log('(no members)');
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
for (const m of members) {
|
|
120
|
+
const pend = m.status === 'pending' ? ' (pending)' : '';
|
|
121
|
+
console.log(` #${m.user_id} ${m.identifier} [${m.role}]${pend}`);
|
|
122
|
+
}
|
|
123
|
+
return 0;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(`✗ members failed: ${(err as Error).message}`);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function inviteCmd(args: string[]): Promise<number> {
|
|
131
|
+
const identifier = args.filter((a) => !a.startsWith('-'))[0];
|
|
132
|
+
if (!identifier) {
|
|
133
|
+
console.error('usage: athsra org invite <identifier> [--role=member|admin]');
|
|
134
|
+
return 2;
|
|
135
|
+
}
|
|
136
|
+
let role: 'member' | 'admin' = 'member';
|
|
137
|
+
for (const a of args) {
|
|
138
|
+
if (a.startsWith('--role=')) {
|
|
139
|
+
const v = a.slice('--role='.length);
|
|
140
|
+
if (v !== 'member' && v !== 'admin') {
|
|
141
|
+
console.error('--role must be member|admin');
|
|
142
|
+
return 2;
|
|
143
|
+
}
|
|
144
|
+
role = v;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const ctx = await userCtx();
|
|
148
|
+
if (!ctx) return 1;
|
|
149
|
+
try {
|
|
150
|
+
const res = await ctx.client.inviteMember({ identifier, role });
|
|
151
|
+
const jit = res.jit_created ? ' — 신규 계정 생성' : '';
|
|
152
|
+
console.log(`✓ invited #${res.user_id} (${res.role}, ${res.status})${jit}`);
|
|
153
|
+
console.log(
|
|
154
|
+
' 초대받은 사람: athsra login → `athsra org ls` 에 pending 표시 → `athsra org use` 로 수락',
|
|
155
|
+
);
|
|
156
|
+
return 0;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
const msg = (err as Error).message;
|
|
159
|
+
if (msg.includes('limit_exceeded')) {
|
|
160
|
+
console.error('✗ seat 한도 초과 — owner 가 plan upgrade 필요');
|
|
161
|
+
} else if (msg.includes('disabled')) {
|
|
162
|
+
console.error('✗ 비활성 계정은 초대 불가');
|
|
163
|
+
} else if (msg.includes('already a member')) {
|
|
164
|
+
console.error('✗ 이미 멤버');
|
|
165
|
+
} else if (msg.includes('pending')) {
|
|
166
|
+
console.error('✗ 이미 초대 대기 중');
|
|
167
|
+
} else if (msg.includes('403')) {
|
|
168
|
+
console.error('✗ owner/admin 만 초대 가능');
|
|
169
|
+
} else {
|
|
170
|
+
console.error(`✗ invite failed: ${msg}`);
|
|
171
|
+
}
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function removeCmd(args: string[]): Promise<number> {
|
|
177
|
+
const userId = Number(args[0]);
|
|
178
|
+
if (!Number.isInteger(userId) || userId <= 0) {
|
|
179
|
+
console.error('usage: athsra org remove <user_id> (athsra org members 로 user_id 확인)');
|
|
180
|
+
return 2;
|
|
181
|
+
}
|
|
182
|
+
const ctx = await userCtx();
|
|
183
|
+
if (!ctx) return 1;
|
|
184
|
+
try {
|
|
185
|
+
const res = await ctx.client.removeMember(userId);
|
|
186
|
+
console.log(`✓ removed #${res.user_id} (revoked, ${res.revoked_tokens} token(s) cleared)`);
|
|
187
|
+
// real revocation — DEK 회전(제거 멤버의 캐시 DEK 무효화). owner 만 가능(master 재wrap).
|
|
188
|
+
const role = await currentOrgRole(ctx);
|
|
189
|
+
if (role === 'owner') {
|
|
190
|
+
const rot = await rotateAfterRemoval(ctx.client, ctx.masterPw, userId);
|
|
191
|
+
reportRotation(rot);
|
|
192
|
+
return rot.failed.length > 0 ? 1 : 0;
|
|
193
|
+
}
|
|
194
|
+
console.log(
|
|
195
|
+
` ⚠ DEK 회전은 owner 만 가능 — owner 가 \`athsra org rotate-after-removal ${userId}\` 실행 필요.`,
|
|
196
|
+
);
|
|
197
|
+
return 0;
|
|
198
|
+
} catch (err) {
|
|
199
|
+
const msg = (err as Error).message;
|
|
200
|
+
if (msg.includes('owner')) {
|
|
201
|
+
console.error('✗ org owner 는 제거 불가');
|
|
202
|
+
} else if (msg.includes('403')) {
|
|
203
|
+
console.error('✗ owner/admin 만 타인 제거 가능');
|
|
204
|
+
} else {
|
|
205
|
+
console.error(`✗ remove failed: ${msg}`);
|
|
206
|
+
}
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function rotateAfterRemovalCmd(args: string[]): Promise<number> {
|
|
212
|
+
const userId = Number(args[0]);
|
|
213
|
+
if (!Number.isInteger(userId) || userId <= 0) {
|
|
214
|
+
console.error('usage: athsra org rotate-after-removal <user_id> (제거된 멤버의 user_id)');
|
|
215
|
+
return 2;
|
|
216
|
+
}
|
|
217
|
+
const ctx = await userCtx();
|
|
218
|
+
if (!ctx) return 1;
|
|
219
|
+
const role = await currentOrgRole(ctx);
|
|
220
|
+
if (role !== 'owner') {
|
|
221
|
+
console.error('✗ DEK 회전은 현재 org 의 owner 만 가능합니다 (master pw 로 재wrap).');
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const rot = await rotateAfterRemoval(ctx.client, ctx.masterPw, userId);
|
|
226
|
+
console.log(`✓ rotate-after-removal #${userId}`);
|
|
227
|
+
reportRotation(rot);
|
|
228
|
+
return rot.failed.length > 0 ? 1 : 0;
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.error(`✗ rotate failed: ${(err as Error).message}`);
|
|
231
|
+
return 1;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function useCmd(args: string[]): Promise<number> {
|
|
236
|
+
const target = args[0];
|
|
237
|
+
if (!target) {
|
|
238
|
+
console.error('usage: athsra org use <slug|id>');
|
|
239
|
+
return 2;
|
|
240
|
+
}
|
|
241
|
+
const ctx = await userCtx();
|
|
242
|
+
if (!ctx) return 1;
|
|
243
|
+
const { orgs } = await ctx.client.listOrgs();
|
|
244
|
+
const org = orgs.find((o) => o.slug === target || String(o.id) === target);
|
|
245
|
+
if (!org) {
|
|
246
|
+
console.error(`✗ '${target}' org 멤버 아님 (athsra org ls 로 확인)`);
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const sw = await ctx.client.switchOrg(org.id);
|
|
251
|
+
setToken(ctx.config.machineId, sw.token);
|
|
252
|
+
saveConfig({ ...ctx.config, orgId: sw.org_id, orgSlug: org.slug });
|
|
253
|
+
console.log(`✓ now acting as org ${org.slug} (${sw.role})`);
|
|
254
|
+
return 0;
|
|
255
|
+
} catch (err) {
|
|
256
|
+
const msg = (err as Error).message;
|
|
257
|
+
if (msg.includes('limit_exceeded')) {
|
|
258
|
+
console.error('✗ seat 한도 초과 — owner 가 plan upgrade 필요');
|
|
259
|
+
} else {
|
|
260
|
+
console.error(`✗ switch failed: ${msg}`);
|
|
261
|
+
}
|
|
262
|
+
return 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function leaveCmd(): Promise<number> {
|
|
267
|
+
const ctx = await userCtx();
|
|
268
|
+
if (!ctx) return 1;
|
|
269
|
+
const me = await ctx.client.whoami();
|
|
270
|
+
if (me.userId === undefined) {
|
|
271
|
+
console.error('✗ token 에 user_id 없음 — athsra login');
|
|
272
|
+
return 1;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
await ctx.client.removeMember(me.userId);
|
|
276
|
+
// self-leave 가 현재 org token 을 삭제 → config.orgId clear (다음 명령의 idle-refresh 가
|
|
277
|
+
// personal org 로 re-register). personal 컨텍스트 복귀.
|
|
278
|
+
saveConfig({ ...ctx.config, orgId: undefined, orgSlug: undefined });
|
|
279
|
+
console.log('✓ left the org — personal 컨텍스트로 복귀 (다음 명령에서 자동 재인증).');
|
|
280
|
+
return 0;
|
|
281
|
+
} catch (err) {
|
|
282
|
+
const msg = (err as Error).message;
|
|
283
|
+
if (msg.includes('owner')) {
|
|
284
|
+
console.error('✗ owner 는 탈퇴 불가 (소유 이양 / org 삭제 필요)');
|
|
285
|
+
} else {
|
|
286
|
+
console.error(`✗ leave failed: ${msg}`);
|
|
287
|
+
}
|
|
288
|
+
return 1;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function grantAccessCmd(args: string[]): Promise<number> {
|
|
293
|
+
const target = args.filter((a) => !a.startsWith('-'))[0];
|
|
294
|
+
const replace = args.includes('--replace');
|
|
295
|
+
if (!target) {
|
|
296
|
+
console.error('usage: athsra org grant-access <identifier|user_id> [--replace]');
|
|
297
|
+
return 2;
|
|
298
|
+
}
|
|
299
|
+
const ctx = await userCtx();
|
|
300
|
+
if (!ctx) return 1;
|
|
301
|
+
// identifier → 현재 org 멤버에서 user_id 해석 (숫자면 그대로).
|
|
302
|
+
let userId = Number(target);
|
|
303
|
+
if (!Number.isInteger(userId) || userId <= 0) {
|
|
304
|
+
const { members } = await ctx.client.listOrgMembers();
|
|
305
|
+
const member = members.find((x) => x.identifier === target);
|
|
306
|
+
if (!member) {
|
|
307
|
+
console.error(`✗ '${target}' 는 현재 org 멤버 아님 (athsra org members 로 확인)`);
|
|
308
|
+
return 1;
|
|
309
|
+
}
|
|
310
|
+
userId = member.user_id;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
// --replace: key reset 후 stale recipient 교체 (제거 후 새 pubkey 로 재포장).
|
|
314
|
+
const res = await grantOrgAccess(ctx.client, ctx.masterPw, userId, { replace });
|
|
315
|
+
const parts = [`granted ${res.granted.length}`, `skipped ${res.skipped.length}`];
|
|
316
|
+
if (res.legacyV1.length) parts.push(`v1 미지원 ${res.legacyV1.length}`);
|
|
317
|
+
if (res.failed.length) parts.push(`failed ${res.failed.length}`);
|
|
318
|
+
console.log(`✓ #${userId} re-wrap — ${parts.join(' · ')}`);
|
|
319
|
+
if (res.granted.length) console.log(` 공유: ${res.granted.join(', ')}`);
|
|
320
|
+
if (res.legacyV1.length) {
|
|
321
|
+
console.log(` v1 envelope(먼저 \`athsra migrate-envelopes\`): ${res.legacyV1.join(', ')}`);
|
|
322
|
+
}
|
|
323
|
+
for (const f of res.failed) console.error(` ✗ ${f.project}: ${f.error}`);
|
|
324
|
+
return res.failed.length > 0 ? 1 : 0;
|
|
325
|
+
} catch (err) {
|
|
326
|
+
console.error(`✗ grant-access failed: ${(err as Error).message}`);
|
|
327
|
+
return 1;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function orgCmd(args: string[]): Promise<number> {
|
|
332
|
+
const action = args[0];
|
|
333
|
+
switch (action) {
|
|
334
|
+
case 'create':
|
|
335
|
+
return createOrgCmd(args.slice(1));
|
|
336
|
+
case 'ls':
|
|
337
|
+
case 'list':
|
|
338
|
+
return lsOrgsCmd();
|
|
339
|
+
case 'members':
|
|
340
|
+
return membersCmd();
|
|
341
|
+
case 'invite':
|
|
342
|
+
return inviteCmd(args.slice(1));
|
|
343
|
+
case 'remove':
|
|
344
|
+
return removeCmd(args.slice(1));
|
|
345
|
+
case 'rotate-after-removal':
|
|
346
|
+
return rotateAfterRemovalCmd(args.slice(1));
|
|
347
|
+
case 'grant-access':
|
|
348
|
+
return grantAccessCmd(args.slice(1));
|
|
349
|
+
case 'use':
|
|
350
|
+
case 'switch':
|
|
351
|
+
return useCmd(args.slice(1));
|
|
352
|
+
case 'leave':
|
|
353
|
+
return leaveCmd();
|
|
354
|
+
default:
|
|
355
|
+
if (!action || action === '--help' || action === '-h') {
|
|
356
|
+
console.log(USAGE);
|
|
357
|
+
return action ? 0 : 2;
|
|
358
|
+
}
|
|
359
|
+
console.error(`Unknown action: ${action}`);
|
|
360
|
+
console.error(USAGE);
|
|
361
|
+
return 2;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
deriveKey,
|
|
3
|
+
ENTERPRISE_KDF,
|
|
4
|
+
fromBase64,
|
|
5
|
+
isValidPhrase,
|
|
6
|
+
normalizePhrase,
|
|
7
|
+
toBase64,
|
|
8
|
+
wordCount,
|
|
9
|
+
} from '@athsra/crypto';
|
|
2
10
|
import { loadAuthContext, type UserAuthContext } from '../lib/auth-context.ts';
|
|
3
|
-
import { isValidPhrase, normalizePhrase, wordCount } from '../lib/bip39.ts';
|
|
4
11
|
import { AthsraClient } from '../lib/client.ts';
|
|
5
12
|
import { readPlain, writePlain } from '../lib/envelope.ts';
|
|
13
|
+
import { rewrapForRotation } from '../lib/identity-key.ts';
|
|
6
14
|
import { setMasterPw, setToken } from '../lib/keyring.ts';
|
|
7
15
|
import { promptConfirm, promptPassword } from '../lib/prompt.ts';
|
|
8
16
|
|
|
@@ -97,13 +105,20 @@ export async function rotateMasterCmd(_args: string[]): Promise<number> {
|
|
|
97
105
|
console.log(` ✓ ${p} (${Object.keys(plain).length} keys)`);
|
|
98
106
|
}
|
|
99
107
|
|
|
100
|
-
// 2.
|
|
108
|
+
// 2. identity 키쌍 재포장 재료 계산 (있으면) — old pw 로 unwrap → new pw 로 wrap. proof 회전
|
|
109
|
+
// 요청에 실어 원자적 갱신(별도 PUT 의 부분실패 = privkey 고아화 제거). 키 보유 시 server 가 필수.
|
|
110
|
+
const keyRewrap = await rewrapForRotation(client, oldPw, newPw);
|
|
111
|
+
if (keyRewrap) {
|
|
112
|
+
console.log('• Re-wrapping identity keypair with new master pw (atomic with rotation)...');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// POST /auth/rotate-master
|
|
101
116
|
console.log('\n• Rotating server-side proof + invalidating all tokens...');
|
|
102
117
|
const info = await client.info();
|
|
103
118
|
const globalSalt = fromBase64(info.global_salt);
|
|
104
119
|
const oldProof = toBase64(deriveKey(oldPw, globalSalt));
|
|
105
120
|
const newProof = toBase64(deriveKey(newPw, globalSalt));
|
|
106
|
-
const rot = await client.rotateMaster(oldProof, newProof);
|
|
121
|
+
const rot = await client.rotateMaster(oldProof, newProof, keyRewrap ?? undefined);
|
|
107
122
|
console.log(` ✓ new token issued (machineId=${rot.machineId})`);
|
|
108
123
|
|
|
109
124
|
// 3. keyring 갱신 + 새 client
|
|
@@ -120,15 +135,19 @@ export async function rotateMasterCmd(_args: string[]): Promise<number> {
|
|
|
120
135
|
|
|
121
136
|
// 4. re-encrypt + PUT (v2 envelope, 자동 마이그레이션)
|
|
122
137
|
console.log(
|
|
123
|
-
`\n• Re-encrypting ${Object.keys(plaintexts).length} projects with new master pw
|
|
138
|
+
`\n• Re-encrypting ${Object.keys(plaintexts).length} projects with new master pw ` +
|
|
139
|
+
'(v2, Argon2id m=256MB enterprise)...',
|
|
124
140
|
);
|
|
125
141
|
for (const [p, plain] of Object.entries(plaintexts)) {
|
|
126
|
-
|
|
142
|
+
// rotate-master = enterprise KDF 마이그레이션 지점 (m=256MB). 이후 routine set 은
|
|
143
|
+
// writePlain 의 보존 로직으로 256MB 유지 (silent downgrade 없음).
|
|
144
|
+
await writePlain(newCtx, p, plain, { kdfParams: ENTERPRISE_KDF });
|
|
127
145
|
console.log(` ✓ ${p}`);
|
|
128
146
|
}
|
|
129
147
|
|
|
130
148
|
console.log(
|
|
131
|
-
`\n✓ master password rotated. ${Object.keys(plaintexts).length} projects re-encrypted
|
|
149
|
+
`\n✓ master password rotated. ${Object.keys(plaintexts).length} projects re-encrypted ` +
|
|
150
|
+
'as v2 (Argon2id m=256MB).',
|
|
132
151
|
);
|
|
133
152
|
console.log(
|
|
134
153
|
' service token recipients (있다면) 손실 — `athsra service-token create` 로 재발급 필요.',
|
package/src/commands/run.ts
CHANGED
|
@@ -30,7 +30,8 @@ export async function runCmd(args: string[]): Promise<number> {
|
|
|
30
30
|
return 2;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// project 전달 — device-login agent (master pw 없는 머신) 의 project-scoped token 해석.
|
|
34
|
+
const ctx = await loadAuthContext(project);
|
|
34
35
|
if (!ctx) return 1;
|
|
35
36
|
|
|
36
37
|
const plain = await readPlain(ctx, project);
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { addServiceRecipient, migrateV1ToV2, type SecretEnvelopeV2 } from '@athsra/crypto';
|
|
2
2
|
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
3
3
|
import { resolveProject } from '../lib/auto-project.ts';
|
|
4
|
+
import { red, yellow } from '../lib/colors.ts';
|
|
5
|
+
|
|
6
|
+
/** worker expiry-notify (apps/worker/src/queue/expiry-notify.ts) 의 최임박 threshold 와 동기. */
|
|
7
|
+
const EXPIRING_SOON_DAYS = 14;
|
|
4
8
|
|
|
5
9
|
const USAGE = [
|
|
6
10
|
'usage:',
|
|
@@ -158,12 +162,19 @@ async function listCmd(args: string[]): Promise<number> {
|
|
|
158
162
|
console.log('');
|
|
159
163
|
const now = Date.now();
|
|
160
164
|
for (const t of tokens) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
let expiresLabel: string;
|
|
166
|
+
if (!t.expires_at) {
|
|
167
|
+
expiresLabel = 'no expiry';
|
|
168
|
+
} else {
|
|
169
|
+
const daysLeft = Math.ceil((new Date(t.expires_at).getTime() - now) / 86_400_000);
|
|
170
|
+
if (daysLeft <= 0) {
|
|
171
|
+
expiresLabel = red(`expired ${t.expires_at}`);
|
|
172
|
+
} else if (daysLeft <= EXPIRING_SOON_DAYS) {
|
|
173
|
+
expiresLabel = yellow(`expires ${t.expires_at} (${daysLeft}일 남음)`);
|
|
174
|
+
} else {
|
|
175
|
+
expiresLabel = `expires ${t.expires_at} (${daysLeft}일 남음)`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
167
178
|
console.log(` ${t.label}`);
|
|
168
179
|
console.log(` project: ${t.project}`);
|
|
169
180
|
console.log(` perms: ${t.perms}`);
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { auditCmd } from './commands/audit.ts';
|
|
|
5
5
|
import { completionCmd } from './commands/completion.ts';
|
|
6
6
|
import { deleteCmd } from './commands/delete.ts';
|
|
7
7
|
import { doctorCmd } from './commands/doctor.ts';
|
|
8
|
+
import { drCmd } from './commands/dr.ts';
|
|
8
9
|
import { getCmd } from './commands/get.ts';
|
|
9
10
|
import { handoffCmd } from './commands/handoff.ts';
|
|
10
11
|
import { initCmd } from './commands/init.ts';
|
|
@@ -15,6 +16,7 @@ import { manifestCmd } from './commands/manifest.ts';
|
|
|
15
16
|
import { mcpCmd } from './commands/mcp.ts';
|
|
16
17
|
import { migrateEnvelopesCmd } from './commands/migrate-envelopes.ts';
|
|
17
18
|
import { newPhraseCmd } from './commands/new-phrase.ts';
|
|
19
|
+
import { orgCmd } from './commands/org.ts';
|
|
18
20
|
import { purgeCmd } from './commands/purge.ts';
|
|
19
21
|
import { restoreCmd } from './commands/restore.ts';
|
|
20
22
|
import { revokeCmd } from './commands/revoke.ts';
|
|
@@ -43,9 +45,11 @@ const commands: Record<string, (args: string[]) => Promise<number>> = {
|
|
|
43
45
|
doctor: doctorCmd,
|
|
44
46
|
'service-token': serviceTokenCmd,
|
|
45
47
|
audit: auditCmd,
|
|
48
|
+
dr: drCmd,
|
|
46
49
|
users: usersCmd,
|
|
47
50
|
role: roleCmd,
|
|
48
51
|
project: projectCmd,
|
|
52
|
+
org: orgCmd,
|
|
49
53
|
'migrate-envelopes': migrateEnvelopesCmd,
|
|
50
54
|
'rotate-master': rotateMasterCmd,
|
|
51
55
|
'new-phrase': newPhraseCmd,
|
|
@@ -85,6 +89,7 @@ Usage:
|
|
|
85
89
|
athsra users {list|invite <id> [--role=R]} RBAC: user 목록 / 신규 invite (admin role 필요)
|
|
86
90
|
athsra role {grant|revoke} <user_id> <R> role 부여/제거 (admin/dev/viewer/auditor/sa)
|
|
87
91
|
athsra project {share|unshare} <p> <uid> per-project ACL share/unshare (--perms=read|write)
|
|
92
|
+
athsra org {create|ls|members|invite|remove|use|leave} 멀티테넌시 org 관리 + 컨텍스트 전환
|
|
88
93
|
athsra migrate-envelopes [--apply] 모든 v1 envelope → v2 일괄 마이그레이션 (idempotent)
|
|
89
94
|
|
|
90
95
|
athsra versions <project> 모든 version + tombstone 상태
|
|
@@ -93,6 +98,8 @@ Usage:
|
|
|
93
98
|
athsra restore <project> tombstone 제거 + 최신 version 활성화
|
|
94
99
|
athsra purge <project> hard-delete (delete --hard 별칭, double-confirm)
|
|
95
100
|
|
|
101
|
+
athsra dr {restore-r2|restore-d1} DR 복원 (backup→STORE / 암호화 D1 dump). dry-run 기본, --execute --confirm
|
|
102
|
+
|
|
96
103
|
athsra rotate-master master pw 변경 (모든 projects re-encrypt)
|
|
97
104
|
athsra new-phrase BIP-39 12-word phrase 생성 (master pw 권장)
|
|
98
105
|
athsra handoff [--accept] 새 머신 추가 (issue / accept)
|
package/src/lib/auth-context.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { deriveKey, fromBase64, toBase64 } from '@athsra/crypto';
|
|
1
2
|
import { AthsraClient } from './client.ts';
|
|
2
3
|
import { type Config, loadConfig } from './config.ts';
|
|
3
|
-
import { getMasterPw, getToken } from './keyring.ts';
|
|
4
|
+
import { getDeviceToken, getMasterPw, getToken, setToken } from './keyring.ts';
|
|
4
5
|
|
|
5
6
|
/** User token mode — keyring 의 master pw + Bearer token 보유, full access. */
|
|
6
7
|
export interface UserAuthContext {
|
|
@@ -41,42 +42,94 @@ export type AuthContext = UserAuthContext | ServiceAuthContext;
|
|
|
41
42
|
*
|
|
42
43
|
* 실패 시 stderr 에 안내 + null 반환. 호출자는 `return 1` 만.
|
|
43
44
|
*/
|
|
44
|
-
export async function loadAuthContext(): Promise<AuthContext | null> {
|
|
45
|
+
export async function loadAuthContext(project?: string): Promise<AuthContext | null> {
|
|
46
|
+
// 1. service token env (CI/headless 명시) — 최우선
|
|
45
47
|
const envToken = process.env.ATHSRA_TOKEN;
|
|
46
48
|
if (envToken?.startsWith('ats_')) {
|
|
47
49
|
return loadServiceContext(envToken);
|
|
48
50
|
}
|
|
49
|
-
|
|
51
|
+
// 2. user context (master pw + token, full access) — 보유 시 우선
|
|
52
|
+
const userCtx = loadUserContext();
|
|
53
|
+
if (userCtx) return userCtx;
|
|
54
|
+
// 3. project-scoped device token (device-login agent) — master pw 없는 머신
|
|
55
|
+
// Phase 3 P3: `athsra login --device --project <p>` 가 keyring 에 저장한 ats_*.
|
|
56
|
+
if (project) {
|
|
57
|
+
const deviceToken = getDeviceToken(project);
|
|
58
|
+
if (deviceToken?.startsWith('ats_')) {
|
|
59
|
+
const svc = await loadServiceContext(deviceToken, { silent: true });
|
|
60
|
+
if (svc) return svc;
|
|
61
|
+
console.error(
|
|
62
|
+
`device token for "${project}" 무효 (만료/revoke). ` +
|
|
63
|
+
`\`athsra login --device --project ${project}\` 로 재발급 (또는 \`athsra login\`).`,
|
|
64
|
+
);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// 4. 아무 자격증명 없음 — 안내
|
|
69
|
+
console.error(
|
|
70
|
+
project
|
|
71
|
+
? `Not authenticated for "${project}". \`athsra login\` (full) 또는 ` +
|
|
72
|
+
`\`athsra login --device --project ${project}\` (agent, master pw 불필요).`
|
|
73
|
+
: 'Not logged in. Run `athsra login` first.',
|
|
74
|
+
);
|
|
75
|
+
return null;
|
|
50
76
|
}
|
|
51
77
|
|
|
78
|
+
/** master pw + token 보유 시 user context. 미보유 시 조용히 null (호출자가 device/안내 처리). */
|
|
52
79
|
function loadUserContext(): UserAuthContext | null {
|
|
53
80
|
const config = loadConfig();
|
|
54
|
-
if (!config)
|
|
55
|
-
console.error('Not logged in. Run `athsra login` first.');
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
81
|
+
if (!config) return null;
|
|
58
82
|
const masterPw = getMasterPw(config.machineId);
|
|
59
83
|
const token = getToken(config.machineId);
|
|
60
|
-
if (!masterPw || !token)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
if (!masterPw || !token) return null;
|
|
85
|
+
const client = new AthsraClient(config.workerUrl, token);
|
|
86
|
+
// Phase 3 — idle timeout 자동 재인증. 401 session_idle_timeout 시 keyring master pw
|
|
87
|
+
// 로 silent re-register (proof = Argon2id(pw + GLOBAL_SALT)) → 새 token keyring 저장.
|
|
88
|
+
// 기기 잠금 (OS keyring 차단) 이 방어선이므로 작업 중엔 끊김 없이, 잠금 시엔 idle 유효.
|
|
89
|
+
client.setIdleRefresh(async () => {
|
|
90
|
+
try {
|
|
91
|
+
const info = await client.info();
|
|
92
|
+
const proof = toBase64(deriveKey(masterPw, fromBase64(info.global_salt)));
|
|
93
|
+
const reg = await client.register(proof, config.machineId);
|
|
94
|
+
let token = reg.token;
|
|
95
|
+
// Phase 4 Slice 6a — re-register 는 자기 personal org 로 token 발급. switch 상태였으면
|
|
96
|
+
// (config.orgId) 다시 그 org 로 전환 — 안 하면 idle 후 사용자 모르게 personal 컨텍스트로 reset.
|
|
97
|
+
if (config.orgId !== undefined) {
|
|
98
|
+
client.setToken(token);
|
|
99
|
+
try {
|
|
100
|
+
token = (await client.switchOrg(config.orgId)).token;
|
|
101
|
+
} catch {
|
|
102
|
+
// switch 실패 (멤버십 변경/org 삭제) — personal org token 유지 (다음 `org use` 로 복구).
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
setToken(config.machineId, token);
|
|
106
|
+
return token;
|
|
107
|
+
} catch {
|
|
108
|
+
return null; // refresh 실패 — 원 401 그대로 (athsra login 안내)
|
|
109
|
+
}
|
|
110
|
+
});
|
|
64
111
|
return {
|
|
65
112
|
kind: 'user',
|
|
66
113
|
config,
|
|
67
114
|
masterPw,
|
|
68
115
|
token,
|
|
69
|
-
client
|
|
116
|
+
client,
|
|
70
117
|
};
|
|
71
118
|
}
|
|
72
119
|
|
|
73
|
-
async function loadServiceContext(
|
|
120
|
+
async function loadServiceContext(
|
|
121
|
+
token: string,
|
|
122
|
+
opts?: { silent?: boolean },
|
|
123
|
+
): Promise<ServiceAuthContext | null> {
|
|
124
|
+
const silent = opts?.silent ?? false;
|
|
74
125
|
const config = loadConfig(); // optional in service mode
|
|
75
126
|
const workerUrl = process.env.ATHSRA_WORKER_URL ?? config?.workerUrl;
|
|
76
127
|
if (!workerUrl) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
128
|
+
if (!silent) {
|
|
129
|
+
console.error(
|
|
130
|
+
'service token 모드인데 worker URL 모름. `ATHSRA_WORKER_URL=https://...` 환경변수 또는 `~/.athsra/config.json` 필요.',
|
|
131
|
+
);
|
|
132
|
+
}
|
|
80
133
|
return null;
|
|
81
134
|
}
|
|
82
135
|
const client = new AthsraClient(workerUrl, token);
|
|
@@ -84,7 +137,9 @@ async function loadServiceContext(token: string): Promise<ServiceAuthContext | n
|
|
|
84
137
|
try {
|
|
85
138
|
me = await client.whoami();
|
|
86
139
|
} catch (err) {
|
|
87
|
-
|
|
140
|
+
if (!silent) {
|
|
141
|
+
console.error(`service token 검증 실패 (worker /auth/whoami): ${(err as Error).message}`);
|
|
142
|
+
}
|
|
88
143
|
return null;
|
|
89
144
|
}
|
|
90
145
|
if (
|
|
@@ -93,7 +148,11 @@ async function loadServiceContext(token: string): Promise<ServiceAuthContext | n
|
|
|
93
148
|
(me.scopePerms !== 'read' && me.scopePerms !== 'write') ||
|
|
94
149
|
!me.recipientId
|
|
95
150
|
) {
|
|
96
|
-
|
|
151
|
+
if (!silent) {
|
|
152
|
+
console.error(
|
|
153
|
+
'whoami 응답에 service token scope 정보 없음 — worker 갱신 필요 (Phase 1.x.8+).',
|
|
154
|
+
);
|
|
155
|
+
}
|
|
97
156
|
return null;
|
|
98
157
|
}
|
|
99
158
|
return {
|