diff --git a/.gitignore b/.gitignore index e87f350..211923d 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ coverage/ # Claude CLAUDE.md +stories/ # Package manager locks (keep pnpm-lock.yaml) package-lock.json diff --git a/changelog.md b/changelog.md index 00833d5..0465bf8 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2025-11-28 - 1.2.0 - feat(tokens) +Add support for organization-owned API tokens and org-level token management + +- ApiToken model: added optional organizationId and createdById fields (persisted and indexed) and new static getOrgTokens method +- auth.interfaces: IApiToken and ICreateTokenDto updated to include organizationId and createdById where appropriate +- TokenService: create token options now accept organizationId and createdById; tokens store org and creator info; added getOrgTokens and revokeAllOrgTokens (with audit logging) +- API: TokenApi now integrates PermissionService to allow organization managers to list/revoke org-owned tokens; GET /api/v1/tokens accepts organizationId query param and token lookup checks org management permissions +- Router: PermissionService instantiated and passed to TokenApi +- UI: api.service types and methods updated — IToken and ITokenScope include organizationId/createdById; getTokens and createToken now support an organizationId parameter and scoped scopes +- .gitignore: added stories/ to ignore + ## 2025-11-28 - 1.1.0 - feat(registry) Add hot-reload websocket, embedded UI bundling, and multi-platform Deno build tasks diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 2d87272..3ed5472 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@stack.gallery/registry', - version: '1.1.0', + version: '1.2.0', description: 'Enterprise-grade multi-protocol package registry' } diff --git a/ts/api/handlers/token.api.ts b/ts/api/handlers/token.api.ts index b6addbd..14b150f 100644 --- a/ts/api/handlers/token.api.ts +++ b/ts/api/handlers/token.api.ts @@ -4,17 +4,22 @@ import type { IApiContext, IApiResponse } from '../router.ts'; import { TokenService } from '../../services/token.service.ts'; +import { PermissionService } from '../../services/permission.service.ts'; import type { ITokenScope, TRegistryProtocol } from '../../interfaces/auth.interfaces.ts'; export class TokenApi { private tokenService: TokenService; + private permissionService: PermissionService; - constructor(tokenService: TokenService) { + constructor(tokenService: TokenService, permissionService?: PermissionService) { this.tokenService = tokenService; + this.permissionService = permissionService || new PermissionService(); } /** * GET /api/v1/tokens + * Query params: + * - organizationId: list org tokens (requires org admin) */ public async list(ctx: IApiContext): Promise { if (!ctx.actor?.userId) { @@ -22,7 +27,20 @@ export class TokenApi { } try { - const tokens = await this.tokenService.getUserTokens(ctx.actor.userId); + const url = new URL(ctx.request.url); + const organizationId = url.searchParams.get('organizationId'); + + let tokens; + if (organizationId) { + // Check if user can manage org + const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId); + if (!canManage) { + return { status: 403, body: { error: 'Not authorized to view organization tokens' } }; + } + tokens = await this.tokenService.getOrgTokens(organizationId); + } else { + tokens = await this.tokenService.getUserTokens(ctx.actor.userId); + } return { status: 200, @@ -33,6 +51,8 @@ export class TokenApi { tokenPrefix: t.tokenPrefix, protocols: t.protocols, scopes: t.scopes, + organizationId: t.organizationId, + createdById: t.createdById, expiresAt: t.expiresAt, lastUsedAt: t.lastUsedAt, usageCount: t.usageCount, @@ -48,6 +68,12 @@ export class TokenApi { /** * POST /api/v1/tokens + * Body: + * - name: token name + * - organizationId: (optional) create org token instead of personal + * - protocols: array of protocols + * - scopes: array of scope objects + * - expiresInDays: (optional) token expiry */ public async create(ctx: IApiContext): Promise { if (!ctx.actor?.userId) { @@ -56,8 +82,9 @@ export class TokenApi { try { const body = await ctx.request.json(); - const { name, protocols, scopes, expiresInDays } = body as { + const { name, organizationId, protocols, scopes, expiresInDays } = body as { name: string; + organizationId?: string; protocols: TRegistryProtocol[]; scopes: ITokenScope[]; expiresInDays?: number; @@ -90,8 +117,18 @@ export class TokenApi { } } + // If creating org token, verify permission + if (organizationId) { + const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId); + if (!canManage) { + return { status: 403, body: { error: 'Not authorized to create organization tokens' } }; + } + } + const result = await this.tokenService.createToken({ userId: ctx.actor.userId, + organizationId, + createdById: ctx.actor.userId, name, protocols, scopes, @@ -108,6 +145,7 @@ export class TokenApi { tokenPrefix: result.token.tokenPrefix, protocols: result.token.protocols, scopes: result.token.scopes, + organizationId: result.token.organizationId, expiresAt: result.token.expiresAt, createdAt: result.token.createdAt, warning: 'Store this token securely. It will not be shown again.', @@ -121,6 +159,7 @@ export class TokenApi { /** * DELETE /api/v1/tokens/:id + * Allows revoking personal tokens or org tokens (if org admin) */ public async revoke(ctx: IApiContext): Promise { if (!ctx.actor?.userId) { @@ -130,12 +169,27 @@ export class TokenApi { const { id } = ctx.params; try { - // Get the token to verify ownership - const tokens = await this.tokenService.getUserTokens(ctx.actor.userId); - const token = tokens.find((t) => t.id === id); + // First check if it's a personal token + const userTokens = await this.tokenService.getUserTokens(ctx.actor.userId); + let token = userTokens.find((t) => t.id === id); + + if (!token) { + // Check if it's an org token and user can manage org + const { ApiToken } = await import('../../models/index.ts'); + const anyToken = await ApiToken.getInstance({ id, isRevoked: false }); + + if (anyToken?.organizationId) { + const canManage = await this.permissionService.canManageOrganization( + ctx.actor.userId, + anyToken.organizationId + ); + if (canManage) { + token = anyToken; + } + } + } if (!token) { - // Either doesn't exist or doesn't belong to user return { status: 404, body: { error: 'Token not found' } }; } diff --git a/ts/interfaces/auth.interfaces.ts b/ts/interfaces/auth.interfaces.ts index 3b033a7..ae18061 100644 --- a/ts/interfaces/auth.interfaces.ts +++ b/ts/interfaces/auth.interfaces.ts @@ -143,6 +143,8 @@ export type TTokenAction = 'read' | 'write' | 'delete' | '*'; export interface IApiToken { id: string; userId: string; + organizationId?: string; // For org-owned tokens + createdById?: string; // Who created the token (for audit) name: string; tokenHash: string; tokenPrefix: string; @@ -276,6 +278,7 @@ export interface ICreateRepositoryDto { export interface ICreateTokenDto { name: string; + organizationId?: string; // For org-owned tokens protocols: TRegistryProtocol[]; scopes: ITokenScope[]; expiresAt?: Date; diff --git a/ts/models/apitoken.ts b/ts/models/apitoken.ts index 884d0aa..0fc039e 100644 --- a/ts/models/apitoken.ts +++ b/ts/models/apitoken.ts @@ -15,6 +15,13 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc { + return await ApiToken.getInstances({ + organizationId, + isRevoked: false, + }); + } + /** * Check if token is valid (not expired, not revoked) */ diff --git a/ts/services/token.service.ts b/ts/services/token.service.ts index 55831cc..85e39ce 100644 --- a/ts/services/token.service.ts +++ b/ts/services/token.service.ts @@ -9,6 +9,8 @@ import { AuditService } from './audit.service.ts'; export interface ICreateTokenOptions { userId: string; + organizationId?: string; // For org-owned tokens + createdById?: string; // Who created the token (defaults to userId) name: string; protocols: TRegistryProtocol[]; scopes: ITokenScope[]; @@ -52,6 +54,8 @@ export class TokenService { const token = new ApiToken(); token.id = await ApiToken.getNewId(); token.userId = options.userId; + token.organizationId = options.organizationId; + token.createdById = options.createdById || options.userId; token.name = options.name; token.tokenHash = tokenHash; token.tokenPrefix = tokenPrefix; @@ -150,6 +154,13 @@ export class TokenService { return await ApiToken.getUserTokens(userId); } + /** + * Get all tokens for an organization + */ + public async getOrgTokens(organizationId: string): Promise { + return await ApiToken.getOrgTokens(organizationId); + } + /** * Revoke a token */ @@ -175,6 +186,18 @@ export class TokenService { return tokens.length; } + /** + * Revoke all tokens for an organization + */ + public async revokeAllOrgTokens(organizationId: string, reason?: string): Promise { + const tokens = await ApiToken.getOrgTokens(organizationId); + for (const token of tokens) { + await token.revoke(reason); + await this.auditService.logTokenRevoked(token.id, token.name); + } + return tokens.length; + } + /** * Check if token has permission for a specific action */ diff --git a/ui/src/app/core/services/api.service.ts b/ui/src/app/core/services/api.service.ts index f07e850..b573791 100644 --- a/ui/src/app/core/services/api.service.ts +++ b/ui/src/app/core/services/api.service.ts @@ -39,11 +39,21 @@ export interface IPackage { updatedAt: string; } +export interface ITokenScope { + protocol: string; + organizationId?: string; + repositoryId?: string; + actions: string[]; +} + export interface IToken { id: string; name: string; tokenPrefix: string; protocols: string[]; + scopes?: ITokenScope[]; + organizationId?: string; + createdById?: string; expiresAt?: string; lastUsedAt?: string; usageCount: number; @@ -179,14 +189,21 @@ export class ApiService { } // Tokens - getTokens(): Observable<{ tokens: IToken[] }> { - return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`); + getTokens(organizationId?: string): Observable<{ tokens: IToken[] }> { + let httpParams = new HttpParams(); + if (organizationId) { + httpParams = httpParams.set('organizationId', organizationId); + } + return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`, { + params: httpParams, + }); } createToken(data: { name: string; + organizationId?: string; protocols: string[]; - scopes: { protocol: string; actions: string[] }[]; + scopes: ITokenScope[]; expiresInDays?: number; }): Observable { return this.http.post( diff --git a/ui/src/app/features/tokens/tokens.component.ts b/ui/src/app/features/tokens/tokens.component.ts index d957f34..0b0a864 100644 --- a/ui/src/app/features/tokens/tokens.component.ts +++ b/ui/src/app/features/tokens/tokens.component.ts @@ -1,9 +1,15 @@ import { Component, inject, signal, OnInit } from '@angular/core'; import { NgClass } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { ApiService, type IToken } from '../../core/services/api.service'; +import { ApiService, type IToken, type ITokenScope, type IOrganization } from '../../core/services/api.service'; import { ToastService } from '../../core/services/toast.service'; +interface IScopeEntry { + protocol: string; + actions: string[]; + organizationId?: string; +} + @Component({ selector: 'app-tokens', standalone: true, @@ -48,17 +54,28 @@ import { ToastService } from '../../core/services/toast.service'; @for (token of tokens(); track token.id) {
  • -
    -
    +
    +

    {{ token.name }}

    - @for (protocol of token.protocols.slice(0, 3); track protocol) { - {{ protocol }} - } - @if (token.protocols.length > 3) { - +{{ token.protocols.length - 3 }} + @if (token.organizationId) { + Org Token + } @else { + Personal }
    -

    + +

    + @for (scope of token.scopes?.slice(0, 4) || []; track $index) { + + {{ scope.protocol === '*' ? 'All' : scope.protocol }} + {{ formatActions(scope.actions) }} + + } + @if ((token.scopes?.length || 0) > 4) { + +{{ (token.scopes?.length || 0) - 4 }} more + } +
    +

    {{ token.tokenPrefix }}... @if (token.expiresAt) { · @@ -73,7 +90,7 @@ import { ToastService } from '../../core/services/toast.service'; · {{ token.usageCount }} uses

    -
    @@ -85,8 +102,8 @@ import { ToastService } from '../../core/services/toast.service'; @if (showCreateModal()) { -
    -
    +
    +
    @@ -98,7 +115,8 @@ import { ToastService } from '../../core/services/toast.service';
    -
    +
    +
    + +
    - -
    - @for (protocol of availableProtocols; track protocol) { + +
    + + @if (organizations().length > 0) { }
    + @if (newToken.organizationId) { + + }
    + + +
    +
    + + +
    + + @if (newToken.scopes.length === 0) { +
    +

    No scopes defined. Add at least one scope.

    +
    + } @else { +
    + @for (scope of newToken.scopes; track $index; let i = $index) { +
    +
    +
    + +
    + + +
    + +
    + +
    + @for (action of availableActions; track action) { + + } +
    +
    + + @if (organizations().length > 0 && !newToken.organizationId) { +
    + + +
    + } +
    + +
    +
    + } +
    + } +
    + +