Build core domain: orgs, users, jobs, locate points, auth, live map
Replaces the device-events demo with the actual product:
- Prisma + PostGIS data layer (postgis/postgis:17-3.5). Lat/lng decimals
are the source of truth; a generated geometry(Point,4326) column with
a GIST index backs bbox queries. Migrations apply on container boot.
- JWT auth (bcryptjs + httpOnly cookie) with public registration that
creates an org; per-org roles (ORG_ADMIN/MEMBER/VIEWER) enforced by
guards on all /orgs/:orgId routes.
- Scoped API keys (X-API-Key, sha256-hashed, shown once) for
programmatic access, manageable by org admins.
- REST API: jobs/tickets CRUD with filters, points query (time range,
recordedAt cursor, bbox), members, devices, api-keys.
- MQTT ingest: devices publish to devices/{username}/points and /jobs;
unknown tickets auto-create stub jobs (source=DEVICE); every message
is raw-logged to device_events; acks on devices/{username}/jobs/ack.
Broker gets a dedicated backend user; testuser is now a plain device.
- Realtime: plain-WS gateway at /api/ws (socket.io removed) with
cookie auth and per-job channels feeding the map live.
- Next.js frontend: login/register, jobs list with filters, job detail
with live Google map (APWA utility colors, polylines per run) behind
a provider-neutral JobMap abstraction for a future Esri swap, and
settings pages for members/devices/api-keys.
- Seed: Umagul org, admin user, testuser device, demo job with RTK
points. Sample publisher updated to the new topic contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
51
backend/src/auth/guards/org-roles.guard.ts
Normal file
51
backend/src/auth/guards/org-roles.guard.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { OrgRole } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import { Principal, ROLE_RANK } from '../principal';
|
||||
|
||||
// Runs after UserOrApiKeyGuard on org-scoped routes (/orgs/:orgId/...).
|
||||
// Users: must be a member of the org, with at least the @Roles() role if present.
|
||||
// API keys: must belong to the org (scopes are checked by ScopesGuard).
|
||||
@Injectable()
|
||||
export class OrgRolesGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const principal: Principal | undefined = req.user;
|
||||
const orgId: string | undefined = req.params?.orgId;
|
||||
if (!principal || !orgId) {
|
||||
throw new ForbiddenException('Organization scope required');
|
||||
}
|
||||
|
||||
if (principal.type === 'apiKey') {
|
||||
if (principal.orgId !== orgId) {
|
||||
throw new ForbiddenException('API key does not belong to this organization');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const membership = await this.prisma.orgMembership.findUnique({
|
||||
where: { orgId_userId: { orgId, userId: principal.userId } },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('Not a member of this organization');
|
||||
}
|
||||
|
||||
const required = this.reflector.getAllAndOverride<OrgRole | undefined>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (required && ROLE_RANK[membership.role] < ROLE_RANK[required]) {
|
||||
throw new ForbiddenException(`Requires ${required} role`);
|
||||
}
|
||||
|
||||
req.membership = membership;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user