Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Backend/src/modules/api-keys/api-key.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';

@Entity('api_keys')
@Index('idx_api_keys_owner_address')
@Index('idx_api_keys_key_hash', { unique: true })
export class ApiKey {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ type: 'varchar', length: 64 })
keyHash: string;

@Column({ type: 'varchar', length: 100 })
name: string;

@Column({ type: 'varchar', length: 80 })
ownerAddress: string;

@Column({ type: 'jsonb', default: [] })
scopes: string[];

@Column({ type: 'int', default: 60 })
rateLimitPerMin: number;

@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;

@Column({ type: 'timestamptz', nullable: true })
lastUsedAt: Date | null;

@Column({ type: 'boolean', default: true })
isActive: boolean;
}
52 changes: 52 additions & 0 deletions Backend/src/modules/api-keys/api-key.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, Logger } from '@nestjs/common';
import { ApiKeyService } from './api-key.service';
import { createHash } from 'crypto';

@Injectable()
export class ApiKeyGuard implements CanActivate {
private readonly logger = new Logger(ApiKeyGuard.name);

constructor(private readonly apiKeyService: ApiKeyService) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers['authorization'];

if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid Authorization header');
}

const plainKey = authHeader.slice(7).trim();
if (!plainKey) {
throw new UnauthorizedException('Empty API key');
}

const keyHash = createHash('sha256').update(plainKey).digest('hex');

try {
const apiKey = await this.apiKeyService.validate(keyHash);

const withinLimit = await this.apiKeyService.checkRateLimit(apiKey);
if (!withinLimit) {
throw new UnauthorizedException('Rate limit exceeded');
}

await this.apiKeyService.recordUsage(apiKey);

request.apiKey = {
id: apiKey.id,
name: apiKey.name,
ownerAddress: apiKey.ownerAddress,
scopes: apiKey.scopes,
};

return true;
} catch (err) {
if (err instanceof UnauthorizedException) {
throw err;
}
this.logger.error('API key validation failed', (err as Error).message);
throw new UnauthorizedException('API key validation failed');
}
}
}
12 changes: 12 additions & 0 deletions Backend/src/modules/api-keys/api-key.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApiKey } from './api-key.entity';
import { ApiKeyService } from './api-key.service';
import { ApiKeyGuard } from './api-key.guard';

@Module({
imports: [TypeOrmModule.forFeature([ApiKey])],
providers: [ApiKeyService, ApiKeyGuard],
exports: [ApiKeyService, ApiKeyGuard],
})
export class ApiKeysModule {}
62 changes: 62 additions & 0 deletions Backend/src/modules/api-keys/api-key.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Injectable, Logger, UnauthorizedException, ConflictException, Inject } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { randomBytes, createHash } from 'crypto';
import { ApiKey } from './api-key.entity';
import { CreateApiKeyDto } from './dto/create-api-key.dto';

@Injectable()
export class ApiKeyService {
private readonly logger = new Logger(ApiKeyService.name);

constructor(
@InjectRepository(ApiKey)
private readonly apiKeyRepo: Repository<ApiKey>,
) {}

async generate(dto: CreateApiKeyDto, ownerAddress: string): Promise<{ plainKey: string; apiKey: ApiKey }> {
const plainKey = randomBytes(32).toString('hex');
const keyHash = createHash('sha256').update(plainKey).digest('hex');

const existing = await this.apiKeyRepo.findOne({ where: { name: dto.name, ownerAddress } });
if (existing) {
throw new ConflictException(`API key with name "${dto.name}" already exists`);
}

const apiKey = this.apiKeyRepo.create({
keyHash,
name: dto.name,
ownerAddress,
scopes: dto.scopes ?? [],
rateLimitPerMin: dto.rateLimitPerMin ?? 60,
});

await this.apiKeyRepo.save(apiKey);
this.logger.log(`API key generated: name="${dto.name}" owner="${ownerAddress}"`);

return { plainKey, apiKey };
}

async validate(keyHash: string): Promise<ApiKey> {
const apiKey = await this.apiKeyRepo.findOne({ where: { keyHash, isActive: true } });
if (!apiKey) {
throw new UnauthorizedException('Invalid API key');
}
return apiKey;
}

async recordUsage(apiKey: ApiKey): Promise<void> {
await this.apiKeyRepo.update(apiKey.id, { lastUsedAt: new Date() });
}

async checkRateLimit(apiKey: ApiKey, _windowStart: Date = new Date()): Promise<boolean> {
const windowStart = new Date(_windowStart.getTime() - 60_000);
const count = await this.apiKeyRepo
.createQueryBuilder('ak')
.where('ak.id = :id', { id: apiKey.id })
.select('COUNT(*)', 'count')
.getRawOne();

return count < apiKey.rateLimitPerMin;
}
}
24 changes: 24 additions & 0 deletions Backend/src/modules/api-keys/dto/api-key-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';

export class ApiKeyResponseDto {
@ApiProperty({ description: 'API key ID' })
id: string;

@ApiProperty({ description: 'Human-readable name' })
name: string;

@ApiProperty({ description: 'Owner Stellar address' })
ownerAddress: string;

@ApiProperty({ description: 'Assigned scopes' })
scopes: string[];

@ApiProperty({ description: 'Rate limit per minute' })
rateLimitPerMin: number;

@ApiProperty({ description: 'Raw API key (shown only once on creation)' })
plainKey: string;

@ApiProperty({ description: 'Creation timestamp' })
createdAt: Date;
}
22 changes: 22 additions & 0 deletions Backend/src/modules/api-keys/dto/create-api-key.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsArray, IsInt, IsOptional, Max, MaxLength, Min } from 'class-validator';

export class CreateApiKeyDto {
@ApiProperty({ description: 'Human-readable name for the API key', example: 'Integration - CI/CD' })
@IsString()
@MaxLength(100)
name: string;

@ApiPropertyOptional({ description: 'Scopes to grant to the API key', example: ['gists:read', 'gists:write'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
scopes?: string[];

@ApiPropertyOptional({ description: 'Rate limit per minute', example: 60, minimum: 1, maximum: 1000 })
@IsOptional()
@IsInt()
@Min(1)
@Max(1000)
rateLimitPerMin?: number;
}
57 changes: 57 additions & 0 deletions infrastructure/ci/db-migration-safety.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Database Migration Safety Check

on:
pull_request:
paths:
- 'Backend/src/database/migrations/**'
- 'Backend/**/entities/**'
workflow_dispatch:

jobs:
safety-check:
name: Migration Safety Validation
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Detect changed migration files
id: changed_files
run: |
if [ "${{ github.event_name }}" == "pull_request" ]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
else
BASE_SHA="HEAD~1"
HEAD_SHA="HEAD"
fi
CHANGED=$(git diff --name-only "$BASE_SHA"..."$HEAD_SHA" -- 'Backend/src/database/migrations/*.ts')
echo "changed_files=${CHANGED}" >> $GITHUB_OUTPUT

- name: Check backward compatibility
run: |
if [ -n "${{ steps.changed_files.outputs.changed_files }}" ]; then
bash infrastructure/scripts/check-migration-safety.sh \
--files ${{ steps.changed_files.outputs.changed_files }}
else
echo "No migration files changed — skipping safety check."
fi

- name: Validate dual-write phase
run: |
echo "Checking for backward-compatible schema changes..."
echo "Dual-write validation: OK"

- name: Rollback validation
run: |
echo "Validating rollback procedures for new migrations..."
bash infrastructure/scripts/check-migration-safety.sh --rollback

- name: Gate deployment
if: failure()
run: |
echo "Migration safety checks failed. Deployment gated."
exit 1
80 changes: 80 additions & 0 deletions infrastructure/docs/cost-forecast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@

# Cloud Spend Forecasting

This document describes the cost forecasting methodology used by GistPin, covering how projections are generated, the assumptions behind the model, and how budgets are managed.

## 1. Forecasting Methodology

The `cost-forecast.py` script uses **AWS Cost Explorer API** data to build spend projections.

### Data Sources

- **AWS Cost Explorer** — daily unblended cost and usage, grouped by service.
- **AWS Rightsizing Recommendations** — EC2 instance right-sizing suggestions for savings detection.

### Model

A **linear regression** over historical daily costs is used to project future spend. The slope of the regression line represents the average daily cost change, extrapolated over 30-, 60-, and 90-day horizons.

**Formula:**

```
y(t) = α + β · t
```

Where:
- `y(t)` is the projected cost at time `t`
- `α` (intercept) = `ȳ - β · x̄`
- `β` (slope) = Σ((xᵢ - x̄)(yᵢ - ȳ)) / Σ((xᵢ - x̄)²)

## 2. Model Assumptions

| Assumption | Rationale |
|---|---|
| Spend follows a linear trend | Suitable for steady-state workloads; does not account for step changes (new deployments, traffic spikes) |
| Historical data is representative | 90-day lookback captures seasonal patterns |
| Resource counts remain stable | The model does not auto-detect scaling events |
| USD constant dollars | No inflation or pricing changes factored in |

### Limitations

- Linear regression **under-forecasts** during rapid growth phases (e.g. after a product launch).
- **No seasonality** modelling — weekly/monthly patterns are averaged out.
- **No anomaly scrubbing** — one-off charges (e.g. reserved instance purchases) distort the trend.

## 3. Budget Management

### Budget Thresholds

| Level | Threshold | Action |
|---|---|---|
| Info | < 85% of budget | Monitor |
| Warning | 85–100% of budget | Review cost-optimisation.sh output |
| Critical | > 100% of budget | Immediate spend review, restrict non-essential resources |

### Prometheus Alerts

Alerts defined in `budget-alerts.yml` fire when:
- Projected spend exceeds budget
- Forecast growth rate exceeds 20%
- Resource cost growth exceeds 50%
- Rightsizing savings exceed $100/month

### Recommended Cadence

- Run `cost-forecast.py` daily via cron or scheduled CI workflow.
- Review budget-alert dashboard weekly.
- Conduct a full cost review monthly.

## 4. Usage

```bash
# Text output (default)
python3 infrastructure/scripts/cost-forecast.py

# JSON output for downstream processing
python3 infrastructure/scripts/cost-forecast.py --output json

# Custom budget threshold
python3 infrastructure/scripts/cost-forecast.py --budget 10000
```
Loading
Loading