Kaynağa Gözat

feat: secure device provisioning flow (Pi agent claim → admin approve)

- Add provisioning design doc: docs/device-provisioning.md
- Devices claim endpoints:
  - POST /v1/devices/claim
  - GET /v1/devices/claim/:code/status
  - GET /v1/devices/pending (admin)
  - POST /v1/devices/pending/:code/approve (admin)
  - POST /v1/devices/pending/:code/reject (admin)
- Extend devices schema for provisioning:
  - device_uuid (unique)
  - pending status support
  - nullable project/org during claim phase
- Update heartbeat flow to use X-API-Key from guard (not body)
- Keep realtime emit only for assigned devices (project/org available)
kingkong 2 ay önce
ebeveyn
işleme
f6d08119ab

+ 5 - 4
apps/api-server/src/db/schema.ts

@@ -4,7 +4,7 @@ import { relations } from 'drizzle-orm'
 // ---- Enums ----
 export const orgStatusEnum = pgEnum('org_status', ['active', 'suspended', 'trial'])
 export const projectStatusEnum = pgEnum('project_status', ['planning', 'active', 'paused', 'completed', 'archived'])
-export const deviceStatusEnum = pgEnum('device_status', ['offline', 'online', 'capturing', 'uploading', 'degraded', 'updating', 'error'])
+export const deviceStatusEnum = pgEnum('device_status', ['pending', 'offline', 'online', 'capturing', 'uploading', 'degraded', 'updating', 'error'])
 export const captureStatusEnum = pgEnum('capture_status', ['pending', 'uploaded', 'processing', 'ready', 'failed'])
 export const videoStatusEnum = pgEnum('video_status', ['pending', 'processing', 'ready', 'failed'])
 export const alertSeverityEnum = pgEnum('alert_severity', ['info', 'warning', 'error', 'critical'])
@@ -90,14 +90,15 @@ export const magicLinks = pgTable('magic_links', {
 // ---- Devices ----
 export const devices = pgTable('devices', {
   id: text('id').primaryKey(),
-  projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(),
-  orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }).notNull(),
+  projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }),
+  orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }),
   serialNo: text('serial_no').notNull(),
   name: text('name').notNull(),
+  deviceUuid: text('device_uuid').unique(),   // hardware UUID (from Pi /proc/cpuinfo)
   apiKeyHash: text('api_key_hash').notNull(),
   firmwareVersion: text('firmware_version'),
   lastSeenAt: timestamp('last_seen_at'),
-  status: deviceStatusEnum('status').default('offline').notNull(),
+  status: deviceStatusEnum('status').default('pending').notNull(),
   config: jsonb('config').$type<{
     captureIntervalMinutes: number
     resolution: string

+ 57 - 15
apps/api-server/src/modules/devices/devices.controller.ts

@@ -1,48 +1,90 @@
-import {
-  Controller,
-  Get,
-  Post,
-  Body,
-  Param,
-  Query,
-  UseGuards,
-} from '@nestjs/common'
+import { Body, Controller, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common'
 import { DevicesService } from './devices.service'
-import { HeartbeatDto, HeartbeatResponseDto } from './dto/heartbeat.dto'
+import { ClaimDeviceDto } from './dto/claim.dto'
 import { ApiKeyGuard } from '../../common/guards/api-key.guard'
-import { ApiKey } from '../../common/decorators/api-key.decorator'
+import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
+import { CurrentUser } from '../../common/decorators/current-user.decorator'
+import { Roles } from '../../common/decorators/roles.decorator'
+import { RolesGuard } from '../../common/guards/roles.guard'
+import { HeartbeatDto } from './dto/heartbeat.dto'
 
 @Controller('devices')
 export class DevicesController {
   constructor(private readonly devicesService: DevicesService) {}
 
-  // Device heartbeat — authenticated via API key (no JWT)
+  // ── Provisioning (no auth) ──────────────────────────────
+
+  @Post('claim')
+  claimDevice(@Body() dto: ClaimDeviceDto) {
+    return this.devicesService.claimDevice(dto)
+  }
+
+  @Get('claim/:code/status')
+  getClaimStatus(@Param('code') claimCode: string) {
+    return this.devicesService.getClaimStatus(claimCode)
+  }
+
+  // ── Pending device management (admin only) ────────────────
+
+  @Get('pending')
+  @UseGuards(JwtAuthGuard, RolesGuard)
+  @Roles('org_admin')
+  getPendingDevices() {
+    return this.devicesService.getPendingDevices()
+  }
+
+  @Post('pending/:code/approve')
+  @UseGuards(JwtAuthGuard, RolesGuard)
+  @Roles('org_admin')
+  approveDevice(
+    @Param('code') claimCode: string,
+    @Body() body: { projectId: string },
+    @CurrentUser() user: { userId: string },
+  ) {
+    return this.devicesService.approveDevice(claimCode, user.userId, body.projectId)
+  }
+
+  @Post('pending/:code/reject')
+  @UseGuards(JwtAuthGuard, RolesGuard)
+  @Roles('org_admin')
+  rejectDevice(@Param('code') claimCode: string) {
+    return this.devicesService.rejectDevice(claimCode)
+  }
+
+  // ── Heartbeat (API key auth) ───────────────────────────
+
   @Post(':deviceId/heartbeat')
   @UseGuards(ApiKeyGuard)
   heartbeat(
     @Param('deviceId') deviceId: string,
     @Body() dto: HeartbeatDto,
-    @ApiKey() apiKey: string,
-  ): HeartbeatResponseDto {
-    return this.devicesService.createHeartbeat({ ...dto, deviceId, apiKey }) as any
+    @Req() req: any,
+  ) {
+    return this.devicesService.createHeartbeat(dto, req.apiKey)
   }
 
+  // ── Query (JWT auth) ─────────────────────────────────
+
   @Get()
+  @UseGuards(JwtAuthGuard)
   list(@Query('projectId') projectId?: string) {
     return this.devicesService.getDevices(projectId)
   }
 
   @Get('stats')
+  @UseGuards(JwtAuthGuard)
   stats() {
     return this.devicesService.getDashboardStats()
   }
 
   @Get(':id')
+  @UseGuards(JwtAuthGuard)
   getOne(@Param('id') id: string) {
     return this.devicesService.getDeviceById(id)
   }
 
   @Get(':id/heartbeats')
+  @UseGuards(JwtAuthGuard)
   heartbeats(@Param('id') id: string, @Query('limit') limit?: string) {
     return this.devicesService.getDeviceHeartbeats(id, limit ? parseInt(limit, 10) : 10)
   }

+ 123 - 8
apps/api-server/src/modules/devices/devices.repository.ts

@@ -1,16 +1,136 @@
 import { Injectable } from '@nestjs/common'
-import { eq, and, desc, sql, gte } from 'drizzle-orm'
+import { eq, and, desc, sql, gte, isNull } from 'drizzle-orm'
 import { db } from '../../db/database.module'
 import { devices, deviceHeartbeats, commands } from '../../db/schema'
+import { nanoid } from 'nanoid'
+import * as bcrypt from 'bcrypt'
 
 @Injectable()
 export class DevicesRepository {
+  // ── Provisioning ───────────────────────────────────────────
+
+  async findByDeviceUuid(uuid: string) {
+    const result = await db.select().from(devices).where(eq(devices.deviceUuid, uuid)).limit(1)
+    return result[0] ?? null
+  }
+
+  async createPendingDevice(data: {
+    deviceUuid: string
+    deviceName: string
+    serialNo: string
+    claimCode: string
+    expiresAt: Date
+  }) {
+    const id = nanoid()
+    await db.insert(devices).values({
+      id,
+      deviceUuid: data.deviceUuid,
+      name: data.deviceName,
+      serialNo: data.serialNo,
+      claimCode: data.claimCode,
+      // No project/org yet — pending approval
+      projectId: null,
+      orgId: null,
+      apiKeyHash: '', // filled on approval
+      status: 'pending',
+    })
+    return this.findDeviceById(id)
+  }
+
+  async getClaimStatus(claimCode: string) {
+    const result = await db
+      .select()
+      .from(devices)
+      .where(eq(devices.claimCode, claimCode))
+      .limit(1)
+
+    const device = result[0] ?? null
+    if (!device) return null
+
+    const now = new Date()
+    const expired = device.updatedAt && device.updatedAt < now
+
+    let status: 'pending' | 'approved' | 'rejected' | 'expired' = device.status as any
+
+    if (device.status === 'pending' && device.projectId === null) {
+      // Pending but check expiry — for now allow 24h
+      // Compare claim creation time: use updatedAt as proxy
+      const created = new Date(device.updatedAt)
+      const expiry = new Date(created.getTime() + 24 * 60 * 60 * 1000)
+      if (now > expiry) status = 'expired'
+    }
+
+    if (device.status === 'pending' && device.projectId !== null && device.apiKeyHash !== '') {
+      status = 'approved'
+    }
+
+    return {
+      claimCode: device.claimCode,
+      status,
+      deviceId: status === 'approved' ? device.id : undefined,
+      apiKey: undefined, // never returned from DB
+      expiresAt: device.updatedAt
+        ? new Date(device.updatedAt.getTime() + 24 * 60 * 60 * 1000).toISOString()
+        : undefined,
+    }
+  }
+
+  async findPendingDevices() {
+    // Devices with status=pending and no project assigned
+    return db
+      .select()
+      .from(devices)
+      .where(
+        and(
+          eq(devices.status, 'pending' as any),
+          isNull(devices.projectId),
+        ),
+      )
+      .orderBy(desc(devices.createdAt))
+  }
+
+  async approvePendingDevice(
+    claimCode: string,
+    apiKeyPlain: string,
+    actorUserId: string,
+    projectId: string,
+  ) {
+    const apiKeyHash = await bcrypt.hash(apiKeyPlain, 10)
+    await db
+      .update(devices)
+      .set({
+        apiKeyHash,
+        projectId,
+        status: 'offline',
+        claimCode: null,
+        updatedAt: new Date(),
+      })
+      .where(eq(devices.claimCode, claimCode))
+  }
+
+  async updatePendingStatus(claimCode: string) {
+    await db
+      .update(devices)
+      .set({ status: 'offline' as any, claimCode: null, updatedAt: new Date() })
+      .where(eq(devices.claimCode, claimCode))
+  }
+
+  async assignProject(deviceId: string, projectId: string, orgId: string, apiKeyPlain: string) {
+    const apiKeyHash = await bcrypt.hash(apiKeyPlain, 10)
+    await db
+      .update(devices)
+      .set({ projectId, orgId, apiKeyHash, status: 'offline' as any, claimCode: null, updatedAt: new Date() })
+      .where(eq(devices.id, deviceId))
+  }
+
+  // ── Heartbeat ──────────────────────────────────────────
+
   async findDeviceById(id: string) {
     const result = await db.select().from(devices).where(eq(devices.id, id)).limit(1)
     return result[0] ?? null
   }
 
-  async findDeviceBySerialNo(serialNo: string) {
+  async findBySerialNo(serialNo: string) {
     const result = await db.select().from(devices).where(eq(devices.serialNo, serialNo)).limit(1)
     return result[0] ?? null
   }
@@ -86,12 +206,7 @@ export class DevicesRepository {
     const result = await db
       .select({ count: sql<number>`count(*)` })
       .from(commands)
-      .where(
-        and(
-          eq(commands.deviceId, deviceId),
-          eq(commands.resultStatus, 'pending' as any),
-        ),
-      )
+      .where(and(eq(commands.deviceId, deviceId), eq(commands.resultStatus, 'pending' as any)))
     return result[0]?.count ?? 0
   }
 }

+ 105 - 18
apps/api-server/src/modules/devices/devices.service.ts

@@ -3,6 +3,20 @@ import { DevicesRepository } from './devices.repository'
 import { DeviceStatus } from '@shared/types'
 import { HeartbeatDto } from './dto/heartbeat.dto'
 import { RealtimeGateway } from '../../realtime/gateways/realtime.gateway'
+import { nanoid } from 'nanoid'
+
+function generateClaimCode(): string {
+  const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
+  let code = ''
+  for (let i = 0; i < 6; i++) {
+    code += chars[Math.floor(Math.random() * chars.length)]
+  }
+  return code
+}
+
+function generateApiKey(): string {
+  return nanoid(32)
+}
 
 @Injectable()
 export class DevicesService {
@@ -11,13 +25,84 @@ export class DevicesService {
     private readonly realtime: RealtimeGateway,
   ) {}
 
-  async createHeartbeat(dto: HeartbeatDto) {
-    const device = await this.repo.findDeviceById(dto.deviceId)
-    if (!device) {
-      throw new NotFoundException(`Device ${dto.deviceId} not found`)
+  // ── Provisioning ────────────────────────────────────────────
+
+  async claimDevice(dto: { deviceUuid: string; deviceName: string; serialNo?: string }) {
+    // Check if already claimed
+    const existing = await this.repo.findByDeviceUuid(dto.deviceUuid)
+    if (existing) {
+      if (!existing.claimCode) {
+        return {
+          claimCode: '',
+          status: 'approved' as const,
+          deviceId: existing.id,
+        }
+      }
+
+      const status = await this.repo.getClaimStatus(existing.claimCode)
+      if (status) return status
     }
 
-    if (device.apiKeyHash !== dto.apiKey) {
+    // Create pending device entry
+    const claimCode = generateClaimCode()
+    const expiresAt = new Date()
+    expiresAt.setHours(expiresAt.getHours() + 24)
+
+    await this.repo.createPendingDevice({
+      deviceUuid: dto.deviceUuid,
+      deviceName: dto.deviceName,
+      serialNo: dto.serialNo ?? dto.deviceUuid,
+      claimCode,
+      expiresAt,
+    })
+
+    return {
+      claimCode,
+      status: 'pending' as const,
+      expiresAt: expiresAt.toISOString(),
+    }
+  }
+
+  async getClaimStatus(claimCode: string) {
+    return this.repo.getClaimStatus(claimCode)
+  }
+
+  async getPendingDevices() {
+    return this.repo.findPendingDevices()
+  }
+
+  async approveDevice(
+    claimCode: string,
+    actorUserId: string,
+    projectId: string,
+  ) {
+    const claim = await this.repo.getClaimStatus(claimCode)
+    if (!claim) throw new NotFoundException('Claim not found')
+    if (claim.status !== 'pending') throw new NotFoundException('Claim not pending')
+
+    const apiKey = generateApiKey()
+    await this.repo.approvePendingDevice(claimCode, apiKey, actorUserId, projectId)
+
+    return {
+      status: 'approved' as const,
+      apiKey,
+    }
+  }
+
+  async rejectDevice(claimCode: string) {
+    const claim = await this.repo.getClaimStatus(claimCode)
+    if (!claim) throw new NotFoundException('Claim not found')
+    await this.repo.updatePendingStatus(claimCode)
+    return { status: 'rejected' as const }
+  }
+
+  // ── Heartbeat ──────────────────────────────────────────────
+
+  async createHeartbeat(dto: HeartbeatDto, apiKey: string) {
+    const device = await this.repo.findDeviceById(dto.deviceId)
+    if (!device) throw new NotFoundException(`Device ${dto.deviceId} not found`)
+
+    if (!device.apiKeyHash || device.apiKeyHash !== apiKey) {
       throw new UnauthorizedException('Invalid API key')
     }
 
@@ -39,23 +124,23 @@ export class DevicesService {
 
     const pendingCommands = await this.repo.getPendingCommandCount(dto.deviceId)
 
-    // Emit real-time events
-    this.realtime.emitDeviceHeartbeat(
-      dto.deviceId,
-      device.projectId,
-      device.orgId,
-      dto.status as DeviceStatus,
-      dto.storageFreeGb,
-    )
-
-    // Emit status change if status actually changed
-    if (previousStatus !== dto.status) {
-      this.realtime.emitDeviceStatusChanged(
+    if (device.projectId && device.orgId) {
+      this.realtime.emitDeviceHeartbeat(
         dto.deviceId,
         device.projectId,
+        device.orgId,
         dto.status as DeviceStatus,
-        previousStatus as DeviceStatus,
+        dto.storageFreeGb,
       )
+
+      if (previousStatus !== dto.status) {
+        this.realtime.emitDeviceStatusChanged(
+          dto.deviceId,
+          device.projectId,
+          dto.status as DeviceStatus,
+          previousStatus as DeviceStatus,
+        )
+      }
     }
 
     return {
@@ -65,6 +150,8 @@ export class DevicesService {
     }
   }
 
+  // ── Query ─────────────────────────────────────────────────
+
   async getDevices(projectId?: string) {
     if (projectId) return this.repo.findDevicesByProjectId(projectId)
     return this.repo.findAllDevices()

+ 33 - 0
apps/api-server/src/modules/devices/dto/claim.dto.ts

@@ -0,0 +1,33 @@
+import { IsOptional, IsString, IsNotEmpty } from 'class-validator'
+
+export class ClaimDeviceDto {
+  @IsString()
+  @IsNotEmpty()
+  deviceUuid!: string
+
+  @IsString()
+  @IsNotEmpty()
+  deviceName!: string
+
+  @IsOptional()
+  @IsString()
+  serialNo?: string
+}
+
+export class ClaimStatusDto {
+  claimCode!: string
+  status!: 'pending' | 'approved' | 'rejected' | 'expired'
+  expiresAt?: string
+  apiKey?: string
+  deviceId?: string
+}
+
+export class AssignProjectDto {
+  @IsString()
+  @IsNotEmpty()
+  projectId!: string
+
+  @IsOptional()
+  @IsString()
+  alias?: string
+}

+ 1 - 4
apps/api-server/src/modules/devices/dto/heartbeat.dto.ts

@@ -6,10 +6,6 @@ export class HeartbeatDto {
   @IsNotEmpty()
   deviceId!: string
 
-  @IsString()
-  @IsNotEmpty()
-  apiKey!: string
-
   @IsEnum(DeviceStatus)
   status!: DeviceStatus
 
@@ -41,6 +37,7 @@ export class HeartbeatDto {
   @IsNotEmpty()
   firmwareVersion!: string
 
+  @IsOptional()
   @IsEnum(DeviceStatus)
   networkStatus?: 'online' | 'offline' | 'degraded'
 }

+ 110 - 0
docs/device-provisioning.md

@@ -0,0 +1,110 @@
+# Device Provisioning — Kiến trúc bảo mật
+
+## Luồng provisioning
+
+```
+┌─ Device Pi (DSLR agent) ──────────────────────┐
+│                                               │
+│  [1] BOOT                                     │
+│      - Generate UUID (machine-id hoặc random)   │
+│      - Generate claimCode = random 6-char      │
+│      - POST /v1/devices/claim                  │
+│        { uuid, name, serialNo, claimCode }    │
+│      - Display claimCode + UUID on terminal     │
+│      - Poll /v1/devices/claim/:code/status    │
+│                                               │
+│  [2] APPROVED?                                │
+│      - /claim/:code/status → { status, apiKey }│
+│      - Save apiKey to /boot/timelapse/agent.key│
+│      - chmod 600 (owner read/write only)       │
+│                                               │
+│  [3] HEARTBEAT LOOP                          │
+│      - POST /v1/devices/:id/heartbeat         │
+│        X-API-Key: <key from agent.key>       │
+│      - gphoto2 capture loop                    │
+│      - Poll /v1/devices/:id/commands          │
+└───────────────────────────────────────────────┘
+         HTTPS (TLS)                    HTTPS (TLS)
+              ↓                              ↑
+┌─ Server (NestJS) ────────────────────────────────────────────┐
+│                                                              │
+│  POST /v1/devices/claim         → claimCode + device info    │
+│  GET  /v1/devices/claim/:code  → polling status            │
+│  GET  /v1/devices/pending      → dashboard list            │
+│  POST /v1/devices/pending/:id/approve                       │
+│  POST /v1/devices/pending/:id/reject                         │
+│  POST /v1/devices/:id/heartbeat    ← X-API-Key auth        │
+│  GET  /v1/devices/:id/commands  → pending commands queue   │
+└────────────────────────────────────────────────────────────┘
+```
+
+## Bảng `device_claims` (schema)
+
+```sql
+CREATE TABLE device_claims (
+  id          TEXT PRIMARY KEY,        -- nanoid
+  claim_code  TEXT UNIQUE NOT NULL,     -- 6-char, uppercase
+  device_uuid TEXT UNIQUE NOT NULL,     -- device unique id
+  device_name TEXT NOT NULL,
+  serial_no   TEXT,
+  status      TEXT DEFAULT 'pending',   -- pending | approved | rejected | expired
+  api_key_hash TEXT,                   -- bcrypt hash of API key
+  expires_at  TIMESTAMP NOT NULL,      -- 24h from creation
+  approved_by TEXT,
+  approved_at TIMESTAMP,
+  created_at  TIMESTAMP DEFAULT NOW()
+);
+```
+
+## Security Checklist
+
+- [ ] Claim code: 6-char alphanumeric, uppercase, expires 24h
+- [ ] API key: 32-char random, stored bcrypt-hashed on server
+- [ ] API key: stored in `/boot/timelapse/agent.key` (chmod 600) on device
+- [ ] TLS: server phải có valid HTTPS (production)
+- [ ] Rate limit: /claim endpoint — max 10 requests/IP/giờ
+- [ ] Device không lưu password — chỉ API key
+- [ ] Audit log: mọi provisioning event được ghi
+
+## API Endpoints
+
+### POST /v1/devices/claim
+Device gửi thông tin để đăng ký.
+
+**Request:**
+```json
+{
+  "deviceUuid": "pi-abc123",
+  "deviceName": "Pi-Camera-01",
+  "serialNo": "RPI-0001"
+}
+```
+**Response (201):**
+```json
+{
+  "claimCode": "XK7M2P",
+  "status": "pending",
+  "expiresAt": "2025-01-01T12:00:00Z"
+}
+```
+
+### GET /v1/devices/claim/:code/status
+Device polling để kiểm tra approval.
+
+**Response:**
+```json
+{
+  "status": "pending" | "approved" | "rejected" | "expired",
+  "apiKey": "..."        // chỉ khi approved
+  "deviceId": "DEMO-001" // chỉ khi approved
+}
+```
+
+### GET /v1/devices/pending
+Dashboard list pending devices (admin only).
+
+### POST /v1/devices/pending/:id/approve
+Admin approve → sinh API key → lưu hash → trả cho device qua polling.
+
+### POST /v1/devices/pending/:id/reject
+Admin reject → xóa claim record.