278 lines
7.6 KiB
TypeScript
278 lines
7.6 KiB
TypeScript
|
|
/**
|
||
|
|
* IAuthProvider implementation for smartregistry
|
||
|
|
* Integrates Stack.Gallery's auth system with smartregistry's protocol handlers
|
||
|
|
*/
|
||
|
|
|
||
|
|
import * as plugins from '../plugins.ts';
|
||
|
|
import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
||
|
|
import { User } from '../models/user.ts';
|
||
|
|
import { TokenService } from '../services/token.service.ts';
|
||
|
|
import { PermissionService, type TAction } from '../services/permission.service.ts';
|
||
|
|
import { AuditService } from '../services/audit.service.ts';
|
||
|
|
import { AuthService } from '../services/auth.service.ts';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Request actor representing the authenticated entity making a request
|
||
|
|
*/
|
||
|
|
export interface IStackGalleryActor {
|
||
|
|
type: 'user' | 'api_token' | 'anonymous';
|
||
|
|
userId?: string;
|
||
|
|
user?: User;
|
||
|
|
tokenId?: string;
|
||
|
|
ip?: string;
|
||
|
|
userAgent?: string;
|
||
|
|
protocols: TRegistryProtocol[];
|
||
|
|
permissions: {
|
||
|
|
organizationId?: string;
|
||
|
|
repositoryId?: string;
|
||
|
|
canRead: boolean;
|
||
|
|
canWrite: boolean;
|
||
|
|
canDelete: boolean;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Auth provider that implements smartregistry's IAuthProvider interface
|
||
|
|
*/
|
||
|
|
export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProvider {
|
||
|
|
private tokenService: TokenService;
|
||
|
|
private permissionService: PermissionService;
|
||
|
|
private authService: AuthService;
|
||
|
|
|
||
|
|
constructor() {
|
||
|
|
this.tokenService = new TokenService();
|
||
|
|
this.permissionService = new PermissionService();
|
||
|
|
this.authService = new AuthService();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Authenticate a request and return the actor
|
||
|
|
* Called by smartregistry for every incoming request
|
||
|
|
*/
|
||
|
|
public async authenticate(request: plugins.smartregistry.IAuthRequest): Promise<plugins.smartregistry.IRequestActor> {
|
||
|
|
const auditContext = AuditService.withContext({
|
||
|
|
actorIp: request.ip,
|
||
|
|
actorUserAgent: request.userAgent,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Extract auth credentials
|
||
|
|
const authHeader = request.headers?.['authorization'] || request.headers?.['Authorization'];
|
||
|
|
|
||
|
|
// Try Bearer token (API token)
|
||
|
|
if (authHeader?.startsWith('Bearer ')) {
|
||
|
|
const token = authHeader.substring(7);
|
||
|
|
return await this.authenticateWithApiToken(token, request, auditContext);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Try Basic auth (for npm/other CLI tools)
|
||
|
|
if (authHeader?.startsWith('Basic ')) {
|
||
|
|
const credentials = authHeader.substring(6);
|
||
|
|
return await this.authenticateWithBasicAuth(credentials, request, auditContext);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Anonymous access
|
||
|
|
return this.createAnonymousActor(request);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if actor has permission for the requested action
|
||
|
|
*/
|
||
|
|
public async authorize(
|
||
|
|
actor: plugins.smartregistry.IRequestActor,
|
||
|
|
request: plugins.smartregistry.IAuthorizationRequest
|
||
|
|
): Promise<plugins.smartregistry.IAuthorizationResult> {
|
||
|
|
const stackActor = actor as IStackGalleryActor;
|
||
|
|
|
||
|
|
// Anonymous users can only read public packages
|
||
|
|
if (stackActor.type === 'anonymous') {
|
||
|
|
if (request.action === 'read' && request.isPublic) {
|
||
|
|
return { allowed: true };
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
allowed: false,
|
||
|
|
reason: 'Authentication required',
|
||
|
|
statusCode: 401,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check protocol access
|
||
|
|
if (!stackActor.protocols.includes(request.protocol as TRegistryProtocol) &&
|
||
|
|
!stackActor.protocols.includes('*' as TRegistryProtocol)) {
|
||
|
|
return {
|
||
|
|
allowed: false,
|
||
|
|
reason: `Token does not have access to ${request.protocol} protocol`,
|
||
|
|
statusCode: 403,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Map action to TAction
|
||
|
|
const action = this.mapAction(request.action);
|
||
|
|
|
||
|
|
// Resolve permissions
|
||
|
|
const permissions = await this.permissionService.resolvePermissions({
|
||
|
|
userId: stackActor.userId!,
|
||
|
|
organizationId: request.organizationId,
|
||
|
|
repositoryId: request.repositoryId,
|
||
|
|
protocol: request.protocol as TRegistryProtocol,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Check permission
|
||
|
|
let allowed = false;
|
||
|
|
switch (action) {
|
||
|
|
case 'read':
|
||
|
|
allowed = permissions.canRead || (request.isPublic ?? false);
|
||
|
|
break;
|
||
|
|
case 'write':
|
||
|
|
allowed = permissions.canWrite;
|
||
|
|
break;
|
||
|
|
case 'delete':
|
||
|
|
allowed = permissions.canDelete;
|
||
|
|
break;
|
||
|
|
case 'admin':
|
||
|
|
allowed = permissions.canAdmin;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!allowed) {
|
||
|
|
return {
|
||
|
|
allowed: false,
|
||
|
|
reason: `Insufficient permissions for ${request.action} on ${request.resourceType}`,
|
||
|
|
statusCode: 403,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return { allowed: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Authenticate using API token
|
||
|
|
*/
|
||
|
|
private async authenticateWithApiToken(
|
||
|
|
rawToken: string,
|
||
|
|
request: plugins.smartregistry.IAuthRequest,
|
||
|
|
auditContext: AuditService
|
||
|
|
): Promise<IStackGalleryActor> {
|
||
|
|
const result = await this.tokenService.validateToken(rawToken, request.ip);
|
||
|
|
|
||
|
|
if (!result.valid || !result.token || !result.user) {
|
||
|
|
await auditContext.logFailure(
|
||
|
|
'TOKEN_USED',
|
||
|
|
'api_token',
|
||
|
|
result.errorCode || 'UNKNOWN',
|
||
|
|
result.errorMessage || 'Token validation failed'
|
||
|
|
);
|
||
|
|
|
||
|
|
return this.createAnonymousActor(request);
|
||
|
|
}
|
||
|
|
|
||
|
|
await auditContext.log('TOKEN_USED', 'api_token', {
|
||
|
|
resourceId: result.token.id,
|
||
|
|
success: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
type: 'api_token',
|
||
|
|
userId: result.user.id,
|
||
|
|
user: result.user,
|
||
|
|
tokenId: result.token.id,
|
||
|
|
ip: request.ip,
|
||
|
|
userAgent: request.userAgent,
|
||
|
|
protocols: result.token.protocols,
|
||
|
|
permissions: {
|
||
|
|
canRead: true,
|
||
|
|
canWrite: true,
|
||
|
|
canDelete: true,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Authenticate using Basic auth (username:password or username:token)
|
||
|
|
*/
|
||
|
|
private async authenticateWithBasicAuth(
|
||
|
|
credentials: string,
|
||
|
|
request: plugins.smartregistry.IAuthRequest,
|
||
|
|
auditContext: AuditService
|
||
|
|
): Promise<IStackGalleryActor> {
|
||
|
|
try {
|
||
|
|
const decoded = atob(credentials);
|
||
|
|
const [username, password] = decoded.split(':');
|
||
|
|
|
||
|
|
// If password looks like an API token, try token auth
|
||
|
|
if (password?.startsWith('srg_')) {
|
||
|
|
return await this.authenticateWithApiToken(password, request, auditContext);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Otherwise try username/password (email/password)
|
||
|
|
const result = await this.authService.login(username, password, {
|
||
|
|
userAgent: request.userAgent,
|
||
|
|
ipAddress: request.ip,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!result.success || !result.user) {
|
||
|
|
return this.createAnonymousActor(request);
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
type: 'user',
|
||
|
|
userId: result.user.id,
|
||
|
|
user: result.user,
|
||
|
|
ip: request.ip,
|
||
|
|
userAgent: request.userAgent,
|
||
|
|
protocols: ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'],
|
||
|
|
permissions: {
|
||
|
|
canRead: true,
|
||
|
|
canWrite: true,
|
||
|
|
canDelete: true,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
} catch {
|
||
|
|
return this.createAnonymousActor(request);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create anonymous actor
|
||
|
|
*/
|
||
|
|
private createAnonymousActor(request: plugins.smartregistry.IAuthRequest): IStackGalleryActor {
|
||
|
|
return {
|
||
|
|
type: 'anonymous',
|
||
|
|
ip: request.ip,
|
||
|
|
userAgent: request.userAgent,
|
||
|
|
protocols: [],
|
||
|
|
permissions: {
|
||
|
|
canRead: false,
|
||
|
|
canWrite: false,
|
||
|
|
canDelete: false,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Map smartregistry action to our TAction type
|
||
|
|
*/
|
||
|
|
private mapAction(action: string): TAction {
|
||
|
|
switch (action) {
|
||
|
|
case 'read':
|
||
|
|
case 'pull':
|
||
|
|
case 'download':
|
||
|
|
case 'fetch':
|
||
|
|
return 'read';
|
||
|
|
case 'write':
|
||
|
|
case 'push':
|
||
|
|
case 'publish':
|
||
|
|
case 'upload':
|
||
|
|
return 'write';
|
||
|
|
case 'delete':
|
||
|
|
case 'unpublish':
|
||
|
|
case 'remove':
|
||
|
|
return 'delete';
|
||
|
|
case 'admin':
|
||
|
|
case 'manage':
|
||
|
|
return 'admin';
|
||
|
|
default:
|
||
|
|
return 'read';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|