tokens->listNonService($page['limit'], $page['offset']); $total = $this->tokens->countNonService(); $data = array_map(static function (TokenRecord $r): array { return [ 'id' => $r->id, 'kind' => $r->kind->value, 'prefix' => $r->prefix, 'reporter_id' => $r->reporterId, 'consumer_id' => $r->consumerId, 'role' => $r->role?->value, 'expires_at' => $r->expiresAt?->format('Y-m-d\TH:i:s\Z'), 'revoked_at' => $r->revokedAt?->format('Y-m-d\TH:i:s\Z'), 'last_used_at' => $r->lastUsedAt?->format('Y-m-d\TH:i:s\Z'), ]; }, $rows); return self::json($response, 200, [ 'data' => $data, 'page' => $page['page'], 'limit' => $page['limit'], 'total' => $total, ]); } public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $body = self::jsonBody($request); $errors = []; $kindValue = isset($body['kind']) && is_string($body['kind']) ? $body['kind'] : ''; $kind = TokenKind::tryFrom($kindValue); if ($kind === null) { return self::validationFailed($response, ['kind' => 'must be reporter, consumer, or admin']); } if ($kind === TokenKind::Service) { return self::error($response, 400, 'service tokens cannot be created via API'); } $reporterId = self::optInt($body['reporter_id'] ?? null); $consumerId = self::optInt($body['consumer_id'] ?? null); $roleValue = isset($body['role']) && is_string($body['role']) ? $body['role'] : null; $role = $roleValue !== null ? Role::tryFrom($roleValue) : null; $expiresAt = null; if (array_key_exists('expires_at', $body) && $body['expires_at'] !== null) { if (!is_string($body['expires_at'])) { $errors['expires_at'] = 'must be ISO 8601 string'; } else { $parsed = self::parseUtc($body['expires_at']); if ($parsed === null) { $errors['expires_at'] = 'must be ISO 8601 string'; } elseif ($parsed <= $this->clock->now()) { $errors['expires_at'] = 'must be in the future'; } else { $expiresAt = $parsed; } } } switch ($kind) { case TokenKind::Reporter: if ($reporterId === null) { $errors['reporter_id'] = 'required for reporter tokens'; } elseif ($this->reporters->findById($reporterId) === null) { $errors['reporter_id'] = 'unknown reporter'; } if ($consumerId !== null) { $errors['consumer_id'] = 'must be null for reporter tokens'; } if ($roleValue !== null) { $errors['role'] = 'must be null for reporter tokens'; } break; case TokenKind::Consumer: if ($consumerId === null) { $errors['consumer_id'] = 'required for consumer tokens'; } elseif ($this->consumers->findById($consumerId) === null) { $errors['consumer_id'] = 'unknown consumer'; } if ($reporterId !== null) { $errors['reporter_id'] = 'must be null for consumer tokens'; } if ($roleValue !== null) { $errors['role'] = 'must be null for consumer tokens'; } break; case TokenKind::Admin: if ($role === null) { $errors['role'] = 'required for admin tokens (viewer|operator|admin)'; } if ($reporterId !== null) { $errors['reporter_id'] = 'must be null for admin tokens'; } if ($consumerId !== null) { $errors['consumer_id'] = 'must be null for admin tokens'; } break; default: // Unreachable; service was already rejected. break; } if ($errors !== []) { return self::validationFailed($response, $errors); } $raw = $this->issuer->issue($kind); $hash = $this->hasher->hash($raw); $prefix = substr($raw, 0, 8); $this->tokens->create(new TokenRecord( id: null, kind: $kind, hash: $hash, prefix: $prefix, reporterId: $kind === TokenKind::Reporter ? $reporterId : null, consumerId: $kind === TokenKind::Consumer ? $consumerId : null, role: $kind === TokenKind::Admin ? $role : null, expiresAt: $expiresAt, revokedAt: null, lastUsedAt: null, )); $created = $this->tokens->findByHashIncludingInvalid($hash); if ($created === null) { return self::error($response, 500, 'create_failed'); } // Audit payload deliberately excludes the raw token. Prefix is OK. $this->audit->emit( AuditAction::TOKEN_CREATED, 'token', $created->id, [ 'kind' => $created->kind->value, 'prefix' => $created->prefix, 'reporter_id' => $created->reporterId, 'consumer_id' => $created->consumerId, 'role' => $created->role?->value, 'expires_at' => $created->expiresAt?->format('c'), ], self::auditContext($request), self::tokenLabel($created->kind->value, $created->prefix, $created->role?->value), ); return self::json($response, 201, [ 'id' => $created->id, 'kind' => $created->kind->value, 'prefix' => $created->prefix, 'reporter_id' => $created->reporterId, 'consumer_id' => $created->consumerId, 'role' => $created->role?->value, 'expires_at' => $created->expiresAt?->format('Y-m-d\TH:i:s\Z'), 'raw_token' => $raw, ]); } /** * @param array{id: string} $args */ public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface { $id = self::parseId($args['id']); if ($id === null) { return self::error($response, 404, 'not_found'); } $token = $this->tokens->findById($id); if ($token === null) { return self::error($response, 404, 'not_found'); } if ($token->kind === TokenKind::Service) { return self::error($response, 403, 'cannot revoke service tokens via API'); } $this->tokens->revoke($id, $this->clock->now()); $this->audit->emit( AuditAction::TOKEN_REVOKED, 'token', $id, ['kind' => $token->kind->value, 'prefix' => $token->prefix], self::auditContext($request), self::tokenLabel($token->kind->value, $token->prefix, $token->role?->value), ); return $response->withStatus(204); } private static function optInt(mixed $value): ?int { if (is_int($value) && $value > 0) { return $value; } if (is_string($value) && ctype_digit($value) && $value !== '0') { return (int) $value; } return null; } private static function parseUtc(string $iso): ?DateTimeImmutable { try { return new DateTimeImmutable($iso, new DateTimeZone('UTC')); } catch (\Exception) { return null; } } private static function parseId(string $raw): ?int { return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null; } /** * Compose a human-friendly label like "admin viewer (abc12345…)" or * "consumer (zxcvbnm9…)". Includes the role for admin tokens so the * audit reader can tell a viewer-token revoke from an admin-token revoke. */ private static function tokenLabel(string $kind, string $prefix, ?string $role): string { $base = $kind === 'admin' && $role !== null ? sprintf('admin %s', $role) : $kind; return sprintf('%s (%s…)', $base, $prefix); } }