This commit is contained in:
@@ -98,4 +98,69 @@ export function mountAdminApi(
|
||||
deleteUserFolder(userFolderRoot, id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ── Local organizations (admin) ───────────────────────────────────────
|
||||
// A provider-agnostic 'org' visibility scope for local accounts. See
|
||||
// docs/superpowers/plans/2026-06-09-local-orgs.md.
|
||||
|
||||
app.get('/api/admin/orgs', guard, (_req: Request, res: Response) => {
|
||||
if (!authActive) { res.json([]); return; }
|
||||
const orgs = repo.listLocalOrgs().map(o => ({ ...o, members: repo.listOrgMembers(o.id) }));
|
||||
res.json(orgs);
|
||||
});
|
||||
|
||||
app.post('/api/admin/orgs', guard, (req: Request, res: Response) => {
|
||||
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
|
||||
const { name } = (req.body ?? {}) as { name?: string };
|
||||
if (typeof name !== 'string' || !name.trim()) {
|
||||
res.status(400).json({ error: 'name is required' });
|
||||
return;
|
||||
}
|
||||
const createdBy = (req.user as { id?: string } | undefined)?.id ?? null;
|
||||
res.status(201).json(repo.createLocalOrg(name.trim(), createdBy));
|
||||
});
|
||||
|
||||
app.patch('/api/admin/orgs/:id', guard, (req: Request, res: Response) => {
|
||||
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
|
||||
const { id } = req.params;
|
||||
const { name } = (req.body ?? {}) as { name?: string };
|
||||
if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; }
|
||||
if (typeof name !== 'string' || !name.trim()) { res.status(400).json({ error: 'name is required' }); return; }
|
||||
repo.renameLocalOrg(id, name.trim());
|
||||
res.json(repo.getLocalOrg(id));
|
||||
});
|
||||
|
||||
app.delete('/api/admin/orgs/:id', guard, (req: Request, res: Response) => {
|
||||
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
|
||||
const { id } = req.params;
|
||||
if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; }
|
||||
// repo downgrades org-scoped tasks/schedules/jobs/notes to private first.
|
||||
repo.deleteLocalOrg(id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.post('/api/admin/orgs/:id/members', guard, (req: Request, res: Response) => {
|
||||
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
|
||||
const { id } = req.params;
|
||||
const { userId, role } = (req.body ?? {}) as { userId?: string; role?: string };
|
||||
if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; }
|
||||
if (typeof userId !== 'string' || !repo.getUserById(userId)) {
|
||||
res.status(400).json({ error: 'a valid userId is required' });
|
||||
return;
|
||||
}
|
||||
repo.addOrgMember(id, userId, role === 'owner' ? 'owner' : 'member');
|
||||
// The added user's session orgIds change — drop their sessions so the next
|
||||
// request re-derives membership (and 'org'-visible resources appear).
|
||||
repo.deleteSessionsByUserId(userId);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.delete('/api/admin/orgs/:id/members/:userId', guard, (req: Request, res: Response) => {
|
||||
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
|
||||
const { id, userId } = req.params;
|
||||
if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; }
|
||||
repo.removeOrgMember(id, userId);
|
||||
repo.deleteSessionsByUserId(userId);
|
||||
res.status(204).end();
|
||||
});
|
||||
}
|
||||
|
||||
+14
-4
@@ -81,6 +81,17 @@ export function isLocalEnabled(authConfig: AuthConfig): boolean {
|
||||
return authConfig.local?.enabled === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The org ids a user belongs to, for session.orgIds. Union of Gitea orgs
|
||||
* (from OAuth login) and local orgs (membership). buildVisibilityWhere is
|
||||
* provider-agnostic, so 'org' visibility works for either once these are set.
|
||||
*/
|
||||
export function resolveOrgIds(repo: Repository, userId: string): string[] {
|
||||
const gitea = repo.listUserGiteaOrgs(userId).map(o => o.orgId);
|
||||
const local = repo.listUserLocalOrgs(userId).map(o => o.orgId);
|
||||
return [...new Set([...gitea, ...local])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-service password change for the authenticated local user. Mount behind
|
||||
* requireAuth + a JSON body parser. Requires the CURRENT password (so a
|
||||
@@ -497,7 +508,7 @@ function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
|
||||
if (updated) user = updated;
|
||||
}
|
||||
await fetchGiteaOrgsForUser(repo, user.id, baseUrl, accessToken);
|
||||
const orgIds = repo.listUserGiteaOrgs(user.id).map(o => o.orgId);
|
||||
const orgIds = resolveOrgIds(repo, user.id);
|
||||
const sessionUser: Express.User = {
|
||||
...user,
|
||||
orgIds,
|
||||
@@ -589,7 +600,7 @@ function createAuthRouter(
|
||||
|
||||
const toExpressUser = (u: User): Express.User => ({
|
||||
...u,
|
||||
orgIds: [],
|
||||
orgIds: resolveOrgIds(repo, u.id),
|
||||
defaultVisibility: u.defaultVisibility ?? 'private',
|
||||
defaultVisibilityOrgId: u.defaultVisibilityOrgId ?? null,
|
||||
});
|
||||
@@ -717,10 +728,9 @@ export function setupAuth(
|
||||
try {
|
||||
const baseUser = repo.getUserById(id);
|
||||
if (!baseUser) { done(null, false); return; }
|
||||
const orgs = repo.listUserGiteaOrgs(id);
|
||||
const enriched: Express.User = {
|
||||
...baseUser,
|
||||
orgIds: orgs.map(o => o.orgId),
|
||||
orgIds: resolveOrgIds(repo, id),
|
||||
defaultVisibility: baseUser.defaultVisibility ?? 'private',
|
||||
defaultVisibilityOrgId: baseUser.defaultVisibilityOrgId ?? null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* End-to-end: a local-org member sees 'org'-scoped resources; a non-member
|
||||
* does not. Validates the whole chain — local org membership → resolveOrgIds
|
||||
* → session.orgIds → the provider-agnostic buildVisibilityWhere.
|
||||
*
|
||||
* See docs/superpowers/plans/2026-06-09-local-orgs.md.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { Repository } from '../db/repository.js';
|
||||
import { runMigrations } from '../db/migrate.js';
|
||||
import { resolveOrgIds } from './auth.js';
|
||||
|
||||
function viewer(id: string, orgIds: string[]): Express.User {
|
||||
return {
|
||||
id, email: `${id}@x.com`, name: id, avatarUrl: null,
|
||||
role: 'user', status: 'active', orgIds,
|
||||
defaultVisibility: 'private', defaultVisibilityOrgId: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('local orgs — org visibility E2E', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-orgvis-'));
|
||||
repo = new Repository(join(tempDir, 'orchestrator.db'));
|
||||
runMigrations(repo.getDb());
|
||||
});
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
|
||||
});
|
||||
|
||||
it('resolveOrgIds includes the local orgs a user belongs to', () => {
|
||||
const alice = repo.createUser({ email: '[email protected]', name: 'A', role: 'user', status: 'active' }).id;
|
||||
const org = repo.createLocalOrg('Team', alice);
|
||||
repo.addOrgMember(org.id, alice);
|
||||
expect(resolveOrgIds(repo, alice)).toContain(org.id);
|
||||
});
|
||||
|
||||
it('an org member sees an org-scoped task; a non-member does not', async () => {
|
||||
const carol = repo.createUser({ email: '[email protected]', name: 'Carol', role: 'user', status: 'active' }).id;
|
||||
const alice = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' }).id;
|
||||
const bob = repo.createUser({ email: '[email protected]', name: 'Bob', role: 'user', status: 'active' }).id;
|
||||
const org = repo.createLocalOrg('Team', carol);
|
||||
repo.addOrgMember(org.id, carol);
|
||||
repo.addOrgMember(org.id, alice); // alice is a member, bob is not
|
||||
|
||||
await repo.createLocalTask({
|
||||
title: 'org task', body: 'b', ownerId: carol,
|
||||
visibility: 'org', visibilityScopeOrgId: org.id,
|
||||
});
|
||||
|
||||
const aliceTasks = await repo.listLocalTasks({ viewer: viewer(alice, resolveOrgIds(repo, alice)) });
|
||||
const bobTasks = await repo.listLocalTasks({ viewer: viewer(bob, resolveOrgIds(repo, bob)) });
|
||||
|
||||
expect(aliceTasks.some(t => t.title === 'org task')).toBe(true); // member sees it
|
||||
expect(bobTasks.some(t => t.title === 'org task')).toBe(false); // non-member does not
|
||||
});
|
||||
|
||||
it('the org name resolves on display after the COALESCE extension', async () => {
|
||||
const carol = repo.createUser({ email: '[email protected]', name: 'C', role: 'user', status: 'active' }).id;
|
||||
const org = repo.createLocalOrg('Engineering', carol);
|
||||
repo.addOrgMember(org.id, carol);
|
||||
const t = await repo.createLocalTask({
|
||||
title: 'x', body: 'b', ownerId: carol, visibility: 'org', visibilityScopeOrgId: org.id,
|
||||
});
|
||||
const got = await repo.getLocalTask(t.id, { viewer: viewer(carol, resolveOrgIds(repo, carol)) });
|
||||
expect(got?.visibilityScopeOrgName).toBe('Engineering');
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ import { setSessionManager } from '../engine/tools/browser.js';
|
||||
import { setUserFolderToolDeps } from '../engine/tools/user-folder.js';
|
||||
import { setSkillToolDeps } from '../engine/tools/skills.js';
|
||||
import { setAppDocsDeps } from '../engine/tools/app-docs.js';
|
||||
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler } from './auth.js';
|
||||
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, resolveOrgIds } from './auth.js';
|
||||
import { canUserSeeTask } from './visibility.js';
|
||||
import { mountAdminApi } from './admin-api.js';
|
||||
import { createAdminGatewayApi } from './admin-gateway-api.js';
|
||||
@@ -698,7 +698,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
getUserAccess: (userId) => {
|
||||
const user = repo.getUserById(userId);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const orgIds = repo.listUserGiteaOrgs(userId).map((o) => o.orgId);
|
||||
const orgIds = resolveOrgIds(repo, userId);
|
||||
return { isAdmin, orgIds };
|
||||
},
|
||||
sshExec,
|
||||
@@ -757,7 +757,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
avatarUrl: null,
|
||||
role: (user.role === 'admin' ? 'admin' : 'user'),
|
||||
status: 'active',
|
||||
orgIds: repo.listUserGiteaOrgs(user.id).map((o) => o.orgId),
|
||||
orgIds: resolveOrgIds(repo, user.id),
|
||||
defaultVisibility: 'private',
|
||||
defaultVisibilityOrgId: null,
|
||||
};
|
||||
@@ -773,7 +773,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
resolveSshAccess: async (user, session, task) => {
|
||||
const connection = connectionRepo.resolveConnection(session.connectionId);
|
||||
if (!connection) return false;
|
||||
const orgIds = repo.listUserGiteaOrgs(user.id).map((o) => o.orgId);
|
||||
const orgIds = resolveOrgIds(repo, user.id);
|
||||
const decision = accessResolver.resolveAccess({
|
||||
connection,
|
||||
userId: user.id,
|
||||
|
||||
@@ -15,7 +15,10 @@ const passthrough: RequestHandler = (_req, _res, next) => next();
|
||||
export function mountUsersApi(app: Application, repo: Repository, authActive = true): void {
|
||||
const guard = authActive ? requireAuth : passthrough;
|
||||
|
||||
// Viewer's cached Gitea orgs (populated at OAuth callback by the gitea strategy).
|
||||
// Viewer's orgs for the visibility ('org' scope) picker: Gitea orgs (cached
|
||||
// at OAuth callback) + local orgs (membership). Returning both here means
|
||||
// every picker (CreateTaskDialog / Schedules / DetailPanel / Preferences)
|
||||
// lists local orgs without any client change.
|
||||
app.get('/api/users/me/orgs', guard, (req: Request, res: Response) => {
|
||||
const user = req.user as Express.User | undefined;
|
||||
if (!user) {
|
||||
@@ -23,8 +26,9 @@ export function mountUsersApi(app: Application, repo: Repository, authActive = t
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
const orgs = repo.listUserGiteaOrgs(user.id);
|
||||
res.json({ orgs });
|
||||
const gitea = repo.listUserGiteaOrgs(user.id);
|
||||
const local = repo.listUserLocalOrgs(user.id).map(o => ({ orgId: o.orgId, orgName: o.name, fetchedAt: '' }));
|
||||
res.json({ orgs: [...gitea, ...local] });
|
||||
});
|
||||
|
||||
// Update viewer's per-user preferences (currently just default visibility).
|
||||
|
||||
Reference in New Issue
Block a user