mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-30 09:50:37 +02:00
feat(ios): Mac-side daemon (bun/TS) for Tailscale identity gating + USB proxy
On-demand daemon spawns when /ios-qa needs it (single-instance flock + readiness protocol). Owns tailnet ingress: fail-closed tailscaled LocalAPI probe, dual-track /auth/mint (self-service for allowlisted identities, owner-granted via CLI), capability-tier allowlist (observe/interact/mutate/restore), 1h default session TTL (24h hard cap), audit log of every authenticated mutating tailnet request, hashed-identity attempts log. iOS StateServer never directly binds tailnet — identity validation lives Mac-side because iPhones can't reach tailscaled. 67 unit/integration tests covering session-lock concurrency, capability enforcement, fail-closed probe, identity canonicalization, body limits, and boot-token leak proofs.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
// Allowlist tests — codex flagged identity canonicalization gaps.
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
loadAllowlist,
|
||||
findEntry,
|
||||
hasCapability,
|
||||
grantIdentity,
|
||||
revokeIdentity,
|
||||
saveAllowlist,
|
||||
} from '../src/allowlist';
|
||||
|
||||
let tmpDir: string;
|
||||
let listPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-allowlist-'));
|
||||
listPath = join(tmpDir, 'allowlist.json');
|
||||
});
|
||||
|
||||
describe('Allowlist', () => {
|
||||
test('loadAllowlist returns empty on missing file', async () => {
|
||||
const list = await loadAllowlist(listPath);
|
||||
expect(list).toEqual({ version: 1, entries: [] });
|
||||
});
|
||||
|
||||
test('saveAllowlist writes mode 0600 JSON', async () => {
|
||||
await saveAllowlist({
|
||||
version: 1,
|
||||
entries: [{ identity: 'user@example.com', capabilities: ['observe'], expires_at: null }],
|
||||
}, listPath);
|
||||
expect(existsSync(listPath)).toBe(true);
|
||||
const raw = readFileSync(listPath, 'utf-8');
|
||||
expect(JSON.parse(raw).entries[0].identity).toBe('user@example.com');
|
||||
});
|
||||
|
||||
test('findEntry matches exact identity', async () => {
|
||||
const list = {
|
||||
version: 1 as const,
|
||||
entries: [{ identity: 'user@example.com', capabilities: ['mutate' as const], expires_at: null }],
|
||||
};
|
||||
expect(findEntry(list, 'user@example.com')?.identity).toBe('user@example.com');
|
||||
expect(findEntry(list, 'USER@example.com')).toBeNull(); // exact-match only
|
||||
expect(findEntry(list, 'unknown@example.com')).toBeNull();
|
||||
});
|
||||
|
||||
test('findEntry skips expired entries', async () => {
|
||||
const list = {
|
||||
version: 1 as const,
|
||||
entries: [
|
||||
{ identity: 'expired', capabilities: ['observe' as const], expires_at: new Date(Date.now() - 60_000).toISOString() },
|
||||
],
|
||||
};
|
||||
expect(findEntry(list, 'expired')).toBeNull();
|
||||
});
|
||||
|
||||
test('findEntry accepts future expiry', async () => {
|
||||
const list = {
|
||||
version: 1 as const,
|
||||
entries: [
|
||||
{ identity: 'future', capabilities: ['observe' as const], expires_at: new Date(Date.now() + 60_000).toISOString() },
|
||||
],
|
||||
};
|
||||
expect(findEntry(list, 'future')?.identity).toBe('future');
|
||||
});
|
||||
|
||||
test('hasCapability is tier-aware', async () => {
|
||||
const list = {
|
||||
version: 1 as const,
|
||||
entries: [
|
||||
{ identity: 'restore-user', capabilities: ['restore' as const], expires_at: null },
|
||||
{ identity: 'observe-user', capabilities: ['observe' as const], expires_at: null },
|
||||
],
|
||||
};
|
||||
expect(hasCapability(list, 'restore-user', 'observe')).toBe(true);
|
||||
expect(hasCapability(list, 'restore-user', 'interact')).toBe(true);
|
||||
expect(hasCapability(list, 'restore-user', 'mutate')).toBe(true);
|
||||
expect(hasCapability(list, 'restore-user', 'restore')).toBe(true);
|
||||
expect(hasCapability(list, 'observe-user', 'observe')).toBe(true);
|
||||
expect(hasCapability(list, 'observe-user', 'interact')).toBe(false);
|
||||
expect(hasCapability(list, 'observe-user', 'mutate')).toBe(false);
|
||||
expect(hasCapability(list, 'observe-user', 'restore')).toBe(false);
|
||||
});
|
||||
|
||||
test('grantIdentity adds a new entry', async () => {
|
||||
await grantIdentity({
|
||||
identity: 'new@example.com',
|
||||
capability: 'interact',
|
||||
path: listPath,
|
||||
});
|
||||
const list = await loadAllowlist(listPath);
|
||||
expect(list.entries).toHaveLength(1);
|
||||
expect(list.entries[0]!.identity).toBe('new@example.com');
|
||||
expect(list.entries[0]!.capabilities).toContain('interact');
|
||||
});
|
||||
|
||||
test('grantIdentity upgrades an existing entry', async () => {
|
||||
await grantIdentity({ identity: 'u', capability: 'observe', path: listPath });
|
||||
await grantIdentity({ identity: 'u', capability: 'restore', path: listPath });
|
||||
const list = await loadAllowlist(listPath);
|
||||
expect(list.entries).toHaveLength(1);
|
||||
expect(list.entries[0]!.capabilities).toContain('restore');
|
||||
});
|
||||
|
||||
test('grantIdentity with ttl sets expires_at', async () => {
|
||||
await grantIdentity({ identity: 'u', capability: 'observe', ttlSeconds: 3600, path: listPath });
|
||||
const list = await loadAllowlist(listPath);
|
||||
const exp = Date.parse(list.entries[0]!.expires_at!);
|
||||
expect(exp).toBeGreaterThan(Date.now());
|
||||
expect(exp).toBeLessThan(Date.now() + 3700 * 1000);
|
||||
});
|
||||
|
||||
test('revokeIdentity removes the entry', async () => {
|
||||
await grantIdentity({ identity: 'u', capability: 'observe', path: listPath });
|
||||
await revokeIdentity('u', listPath);
|
||||
const list = await loadAllowlist(listPath);
|
||||
expect(list.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Codex-flagged identity canonicalization variants — verify the matcher
|
||||
// works for each.
|
||||
test('user identity, tagged node, node key, expired node all canonicalize distinctly', async () => {
|
||||
const list = {
|
||||
version: 1 as const,
|
||||
entries: [
|
||||
{ identity: 'alice@example.com', capabilities: ['observe' as const], expires_at: null },
|
||||
{ identity: 'tag:ci', capabilities: ['mutate' as const], expires_at: null },
|
||||
{ identity: 'node:abcdef0123', capabilities: ['observe' as const], expires_at: null },
|
||||
{ identity: 'bob@example.com', capabilities: ['observe' as const], expires_at: new Date(Date.now() - 1000).toISOString() },
|
||||
],
|
||||
};
|
||||
expect(hasCapability(list, 'alice@example.com', 'observe')).toBe(true);
|
||||
expect(hasCapability(list, 'tag:ci', 'mutate')).toBe(true);
|
||||
expect(hasCapability(list, 'node:abcdef0123', 'observe')).toBe(true);
|
||||
expect(hasCapability(list, 'bob@example.com', 'observe')).toBe(false); // expired
|
||||
expect(hasCapability(list, 'tag:CI', 'mutate')).toBe(false); // case-sensitive — canonicalize before lookup
|
||||
});
|
||||
});
|
||||
|
||||
import { afterEach } from 'bun:test';
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
Reference in New Issue
Block a user