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
11 changes: 11 additions & 0 deletions backend/src/controllers/infrastructure.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,17 @@ export class InfrastructureController {
const organizationId = (req as any).user?.organizationId;
const resource = await repository.create(resourceData, organizationId);

if (!resource) {
// Same answer whether the service doesn't exist or belongs to
// another organization.
const response: ApiResponse = {
success: false,
error: 'Service not found',
};
res.status(404).json(response);
return;
}

const response: ApiResponse = {
success: true,
data: resource,
Expand Down
7 changes: 7 additions & 0 deletions backend/src/controllers/services.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ export class ServicesController {
}
const service = await repository.create(serviceData, organizationId);

if (!service) {
// Same answer whether the team doesn't exist or belongs to another
// organization.
next(new NotFoundError('Team'));
return;
}

// Emit onboarding event for service creation
const user = (req as any).user;
if (user && service) {
Expand Down
24 changes: 24 additions & 0 deletions backend/src/middleware/rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,30 @@ export const discoveryRateLimiter = rateLimit({
},
});

/**
* Rate limiter for the manual AWS cost sync
* A sync whose result is not already cached makes a billed Cost Explorer
* call, so it is limited per organization, not per IP.
* In-memory: per backend process.
*/
export const costSyncRateLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour window
max: 10,

standardHeaders: true,
legacyHeaders: false,

keyGenerator: (req: Request) => `cost-sync:${req.user?.organizationId ?? 'unauthenticated'}`,

handler: (req: Request, res: Response) => {
res.status(429).json({
success: false,
error: 'AWS cost sync rate limit reached. Maximum 10 syncs per hour per organization.',
retry_after: 3600,
});
},
});

/**
* Rate limiter for authentication endpoints (login, register)
* Prevents brute force attacks
Expand Down
16 changes: 13 additions & 3 deletions backend/src/repositories/infrastructure.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,23 @@ export class InfrastructureRepository {
return result.rows[0] || null;
}

async create(resource: CreateInfrastructureRequest, organizationId: string): Promise<InfrastructureResource> {
// Inserts only when the referenced service belongs to the resource's own
// organization -- checked and row-locked in the same statement as the
// insert, so the service can't be deleted or moved in between. Returns null
// when it doesn't (missing, or another organization's); callers must not
// distinguish those cases to the client.
async create(resource: CreateInfrastructureRequest, organizationId: string): Promise<InfrastructureResource | null> {
const query = `
INSERT INTO infrastructure_resources (
service_id, resource_type, aws_id, aws_region,
status, cost_per_month, metadata, organization_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
SELECT
s.id, $2::varchar, $3::varchar, $4::varchar,
$5::varchar, $6::numeric, $7::jsonb, s.organization_id
FROM services s
WHERE s.id = $1::uuid AND s.organization_id = $8::uuid
FOR SHARE OF s
RETURNING *
`;
const result = await pool.query(query, [
Expand All @@ -82,7 +92,7 @@ export class InfrastructureRepository {
JSON.stringify(resource.metadata || {}),
organizationId,
]);
return result.rows[0];
return result.rows[0] || null;
}

async delete(id: string, organizationId: string): Promise<boolean> {
Expand Down
42 changes: 33 additions & 9 deletions backend/src/repositories/services.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@ export class ServicesRepository {
return result.rows[0] || null;
}

async create(service: CreateServiceRequest, organizationId: string): Promise<Service> {
const query = `
INSERT INTO services (name, template, owner, team_id, github_url, description, status, organization_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const result = await pool.query(query, [
// With a team: inserts only when that team belongs to the service's own
// organization -- checked and row-locked in the same statement as the
// insert, so the team can't be deleted in between. Returns null when it
// doesn't (missing, or another organization's); callers must not
// distinguish those cases to the client.
async create(service: CreateServiceRequest, organizationId: string): Promise<Service | null> {
const values = [
service.name,
service.template,
service.owner,
Expand All @@ -73,8 +73,32 @@ export class ServicesRepository {
service.description,
'active', // default status
organizationId,
]);
return result.rows[0];
];

if (service.team_id === undefined || service.team_id === null) {
const result = await pool.query(
`
INSERT INTO services (name, template, owner, team_id, github_url, description, status, organization_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`,
values
);
return result.rows[0];
}

const query = `
INSERT INTO services (name, template, owner, team_id, github_url, description, status, organization_id)
SELECT
$1::varchar, $2::varchar, $3::varchar, t.id,
$5::text, $6::text, $7::varchar, t.organization_id
FROM teams t
WHERE t.id = $4::uuid AND t.organization_id = $8::uuid
FOR SHARE OF t
RETURNING *
`;
const result = await pool.query(query, values);
return result.rows[0] || null;
}

async update(id: string, updates: UpdateServiceRequest, organizationId: string): Promise<Service | null> {
Expand Down
Loading
Loading