Skip to content

Commit 220c71e

Browse files
Fuyuan Chengclaude
andcommitted
feat(core): implement multi-tenant architecture
Add comprehensive multi-tenant architecture for cooperative isolation: - Add UserRole types (cooperative_admin, cooperative_staff, farmer, customer) - Define permission matrix with ROLE_PERMISSIONS for RBAC - Create TenantContext and AuthContext for request-scoped authorization - Add authorization helpers (requirePermission, canAccessResource, getQueryScope) - Implement TenantService for onboarding, invitations, and member management - Add database schema for data sharing configs and member invitations - Add cross-cooperative data sharing configuration Security features: - Role-based access control with permission scopes (own, cooperative, shared) - Tenant isolation enforcement with TenantIsolationError - Query scoping helpers for database operations Closes #25 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b0b315a commit 220c71e

12 files changed

Lines changed: 1912 additions & 5 deletions

File tree

packages/core/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*
44
* Core business logic for the minori platform.
55
* Includes harvest prediction, crop database, weather services, price analysis,
6-
* supply-demand matching, and utility functions.
6+
* supply-demand matching, multi-tenant architecture, and utility functions.
77
*/
88

99
export * from './prediction';
@@ -12,3 +12,4 @@ export * from './weather';
1212
export * from './price';
1313
export * from './cooperative';
1414
export * from './matching';
15+
export * from './tenant';
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* Tests for authorization context.
3+
*/
4+
5+
import { describe, expect, test } from 'bun:test';
6+
import {
7+
createAuthContext,
8+
requirePermission,
9+
canAccessResource,
10+
requireResourceAccess,
11+
getQueryScope,
12+
requireTenantMatch,
13+
AuthorizationError,
14+
TenantIsolationError,
15+
} from './auth-context';
16+
import type { TenantContext } from '@minori/shared';
17+
18+
// Helper to create a test context
19+
function createTestContext(overrides: Partial<TenantContext> = {}): TenantContext {
20+
return {
21+
userId: 'user_1',
22+
role: 'farmer',
23+
cooperativeId: 'coop_1',
24+
locale: 'zh-TW',
25+
...overrides,
26+
};
27+
}
28+
29+
describe('createAuthContext', () => {
30+
test('creates context with farmer permissions', () => {
31+
const ctx = createAuthContext(createTestContext({ role: 'farmer' }));
32+
33+
expect(ctx.isAdmin).toBe(false);
34+
expect(ctx.permissions.length).toBeGreaterThan(0);
35+
expect(ctx.hasPermission('record:create')).toBe(true);
36+
expect(ctx.hasPermission('record:read')).toBe(true);
37+
});
38+
39+
test('creates context with admin permissions', () => {
40+
const ctx = createAuthContext(createTestContext({ role: 'cooperative_admin' }));
41+
42+
expect(ctx.isAdmin).toBe(true);
43+
expect(ctx.hasPermission('member:invite')).toBe(true);
44+
expect(ctx.hasPermission('cooperative:update')).toBe(true);
45+
});
46+
47+
test('creates context with staff permissions', () => {
48+
const ctx = createAuthContext(createTestContext({ role: 'cooperative_staff' }));
49+
50+
expect(ctx.isAdmin).toBe(false);
51+
expect(ctx.hasPermission('record:read')).toBe(true);
52+
expect(ctx.hasPermission('member:invite')).toBe(false);
53+
});
54+
55+
test('creates context with customer permissions', () => {
56+
const ctx = createAuthContext(createTestContext({ role: 'customer' }));
57+
58+
expect(ctx.isAdmin).toBe(false);
59+
expect(ctx.hasPermission('demand:create')).toBe(true);
60+
expect(ctx.hasPermission('record:create')).toBe(false);
61+
});
62+
});
63+
64+
describe('hasPermission', () => {
65+
test('returns true for matching action', () => {
66+
const ctx = createAuthContext(createTestContext({ role: 'farmer' }));
67+
68+
expect(ctx.hasPermission('record:create')).toBe(true);
69+
});
70+
71+
test('returns false for non-matching action', () => {
72+
const ctx = createAuthContext(createTestContext({ role: 'farmer' }));
73+
74+
expect(ctx.hasPermission('member:invite')).toBe(false);
75+
});
76+
77+
test('cooperative scope includes own scope', () => {
78+
const ctx = createAuthContext(createTestContext({ role: 'cooperative_admin' }));
79+
80+
expect(ctx.hasPermission('record:read', 'own')).toBe(true);
81+
expect(ctx.hasPermission('record:read', 'cooperative')).toBe(true);
82+
});
83+
84+
test('own scope does not include cooperative scope', () => {
85+
const ctx = createAuthContext(createTestContext({ role: 'farmer' }));
86+
87+
expect(ctx.hasPermission('record:read', 'own')).toBe(true);
88+
expect(ctx.hasPermission('record:read', 'cooperative')).toBe(false);
89+
});
90+
});
91+
92+
describe('requirePermission', () => {
93+
test('does not throw for valid permission', () => {
94+
const ctx = createAuthContext(createTestContext({ role: 'farmer' }));
95+
96+
expect(() => requirePermission(ctx, 'record:create')).not.toThrow();
97+
});
98+
99+
test('throws AuthorizationError for invalid permission', () => {
100+
const ctx = createAuthContext(createTestContext({ role: 'farmer' }));
101+
102+
expect(() => requirePermission(ctx, 'member:invite')).toThrow(AuthorizationError);
103+
});
104+
});
105+
106+
describe('canAccessResource', () => {
107+
test('returns true when user owns resource', () => {
108+
const ctx = createAuthContext(createTestContext({ userId: 'user_1', role: 'farmer' }));
109+
110+
expect(canAccessResource(ctx, 'user_1', 'record:read')).toBe(true);
111+
});
112+
113+
test('returns false when user does not own resource', () => {
114+
const ctx = createAuthContext(createTestContext({ userId: 'user_1', role: 'farmer' }));
115+
116+
expect(canAccessResource(ctx, 'user_2', 'record:read')).toBe(false);
117+
});
118+
119+
test('returns true for admin accessing any resource', () => {
120+
const ctx = createAuthContext(
121+
createTestContext({ userId: 'admin_1', role: 'cooperative_admin' })
122+
);
123+
124+
expect(canAccessResource(ctx, 'user_2', 'record:read')).toBe(true);
125+
});
126+
});
127+
128+
describe('requireResourceAccess', () => {
129+
test('does not throw when user owns resource', () => {
130+
const ctx = createAuthContext(createTestContext({ userId: 'user_1', role: 'farmer' }));
131+
132+
expect(() => requireResourceAccess(ctx, 'user_1', 'record:read')).not.toThrow();
133+
});
134+
135+
test('throws when user does not own resource', () => {
136+
const ctx = createAuthContext(createTestContext({ userId: 'user_1', role: 'farmer' }));
137+
138+
expect(() => requireResourceAccess(ctx, 'user_2', 'record:read')).toThrow(AuthorizationError);
139+
});
140+
});
141+
142+
describe('getQueryScope', () => {
143+
test('returns cooperative scope for admin', () => {
144+
const ctx = createAuthContext(
145+
createTestContext({ cooperativeId: 'coop_1', role: 'cooperative_admin' })
146+
);
147+
148+
const scope = getQueryScope(ctx, 'record:read');
149+
150+
expect(scope.type).toBe('cooperative');
151+
if (scope.type === 'cooperative') {
152+
expect(scope.cooperativeId).toBe('coop_1');
153+
}
154+
});
155+
156+
test('returns user scope for farmer', () => {
157+
const ctx = createAuthContext(
158+
createTestContext({ userId: 'user_1', cooperativeId: 'coop_1', role: 'farmer' })
159+
);
160+
161+
const scope = getQueryScope(ctx, 'record:read');
162+
163+
expect(scope.type).toBe('user');
164+
if (scope.type === 'user') {
165+
expect(scope.cooperativeId).toBe('coop_1');
166+
expect(scope.userId).toBe('user_1');
167+
}
168+
});
169+
170+
test('returns none scope for unauthorized action', () => {
171+
const ctx = createAuthContext(createTestContext({ role: 'farmer' }));
172+
173+
const scope = getQueryScope(ctx, 'member:invite');
174+
175+
expect(scope.type).toBe('none');
176+
});
177+
});
178+
179+
describe('requireTenantMatch', () => {
180+
test('does not throw when cooperative IDs match', () => {
181+
const ctx = createAuthContext(createTestContext({ cooperativeId: 'coop_1' }));
182+
183+
expect(() => requireTenantMatch(ctx, 'coop_1')).not.toThrow();
184+
});
185+
186+
test('throws TenantIsolationError when cooperative IDs do not match', () => {
187+
const ctx = createAuthContext(createTestContext({ cooperativeId: 'coop_1' }));
188+
189+
expect(() => requireTenantMatch(ctx, 'coop_2')).toThrow(TenantIsolationError);
190+
});
191+
192+
test('TenantIsolationError contains both cooperative IDs', () => {
193+
const ctx = createAuthContext(createTestContext({ cooperativeId: 'coop_1' }));
194+
195+
try {
196+
requireTenantMatch(ctx, 'coop_2');
197+
expect(true).toBe(false); // Should not reach here
198+
} catch (error) {
199+
expect(error).toBeInstanceOf(TenantIsolationError);
200+
if (error instanceof TenantIsolationError) {
201+
expect(error.requestedCooperativeId).toBe('coop_2');
202+
expect(error.userCooperativeId).toBe('coop_1');
203+
}
204+
}
205+
});
206+
});

0 commit comments

Comments
 (0)