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
13 changes: 11 additions & 2 deletions services/sla_enforcement/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Linear API Configuration, Highly recommend using an OAuth token
LINEAR_API_KEY=lin_api_your_key_here
# Linear API key — use an OAuth access token (recommended) or a personal API key.
#
# OAuth token (from your OAuth app):
# LINEAR_API_KEY=your_oauth_access_token
#
# Personal API key (from Linear Settings → API):
# LINEAR_API_KEY=lin_api_xxxxxxxxxxxxxxxxxxxx
#
# The agent detects the format automatically and sets the correct
# Authorization header (Bearer <token> for OAuth, raw value for personal keys).
LINEAR_API_KEY=your_oauth_access_token_here
LINEAR_WEBHOOK_SECRET=your_webhook_secret_here

# Slack Configuration (Optional)
Expand Down
478 changes: 478 additions & 0 deletions services/sla_enforcement/IMPLEMENTATION_GUIDE.md

Large diffs are not rendered by default.

352 changes: 310 additions & 42 deletions services/sla_enforcement/README.md

Large diffs are not rendered by default.

84 changes: 78 additions & 6 deletions services/sla_enforcement/config/config.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,93 @@
"priority": true,
"slaCreatedAtBaseline": false
},

"allowlist": [
{
"email": "security-lead@example.com",
"name": "Security Team Lead"
"name": "SLA Admins",
"permissions": ["labels", "sla", "priority", "slaBaseline"],
"members": [
{ "email": "sla-admin@yourcompany.com", "name": "SLA Admin" }
]
},

{
"email": "admin@example.com"
"name": "Engineering Org",
"permissions": ["labels"],
"members": [

{
"name": "Platform Division",
"linearTeamId": "your-platform-team-id",
"permissions": ["labels", "sla"],
"members": [
{
"name": "Platform Team Leads",
"permissions": ["labels", "sla", "priority"],
"members": [
{ "email": "platform-lead@yourcompany.com", "name": "Platform Lead" }
]
}
]
},

{
"name": "Mobile Division",
"linearTeamId": "your-mobile-team-id",
"permissions": ["labels", "sla"],
"members": [
{
"email": "mobile-lead@yourcompany.com",
"name": "Mobile Lead",
"permissions": ["labels", "sla", "priority"]
}
]
}

]
},

{
"id": "linear-user-id-here",
"name": "Authorized User"
"email": "individual@yourcompany.com",
"name": "Individual User (flat entry — backward compatible)"
}
],

"slaRules": [
{
"name": "Delivery Bug SLA",
"teamId": "DELIVERY",
"labels": ["Bug"],
"priorityWindows": [
{ "priority": "urgent", "hours": 24 },
{ "priority": "high", "hours": 168 },
{ "priority": "normal", "hours": 720 },
{ "priority": "low", "hours": 2880 }
]
},
{
"name": "Security Vulnerability SLA",
"labels": ["Vulnerability"],
"priorityWindows": [
{ "priority": "urgent", "hours": 4 },
{ "priority": "high", "hours": 24 },
{ "priority": "normal", "hours": 72 }
]
},
{
"name": "Platform Default SLA",
"teamId": "PLATFORM",
"priorityWindows": [
{ "priority": "urgent", "hours": 8 },
{ "priority": "high", "hours": 72 },
{ "priority": "normal", "hours": 336 },
{ "priority": "low", "hours": 1440 }
]
}
],

"agent": {
"name": "Vulnerability Protection Agent",
"name": "SLA Protection Agent",
"identifier": "🤖 [AGENT]"
},
"slack": {
Expand Down
124 changes: 103 additions & 21 deletions services/sla_enforcement/src/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,29 @@

import * as fs from 'fs';
import * as path from 'path';
import { Config } from './types';
import { Config, AllowlistEntry, AllowlistLeaf, AllowlistGroup, isAllowlistGroup, Permission, ALL_PERMISSIONS } from './types';

const DEFAULT_CONFIG_PATH = path.join(__dirname, '../config/config.json');
const VALID_PERMISSIONS = new Set<Permission>(['labels', 'sla', 'priority', 'slaBaseline']);
const MAX_ALLOWLIST_DEPTH = 10;

/**
* Load and validate configuration from file
* Load and validate configuration from file.
* Normalises legacy flat allowlist entries for backward compatibility.
*/
export function loadConfig(configPath: string = DEFAULT_CONFIG_PATH): Config {
try {
const configContent = fs.readFileSync(configPath, 'utf-8');
const config: Config = JSON.parse(configContent);

const raw = JSON.parse(configContent);

// Normalize legacy flat allowlist entries before validation
if (Array.isArray(raw.allowlist)) {
raw.allowlist = normalizeLegacyAllowlist(raw.allowlist);
}

const config: Config = raw;
validateConfig(config);

return config;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
Expand All @@ -31,55 +40,75 @@ export function loadConfig(configPath: string = DEFAULT_CONFIG_PATH): Config {
}

/**
* Validate configuration structure and required fields
* Coerce legacy flat AllowlistUser entries (no `members`, no `linearTeamId`)
* into AllowlistLeaf objects. New-style entries pass through unchanged.
*
* A legacy entry looks like: { email, id, name }
* It becomes: { email, id, name, permissions: undefined }
* which the engine interprets as "all permissions" — fully backward-compatible.
*/
export function normalizeLegacyAllowlist(entries: any[]): AllowlistEntry[] {
return entries.map(entry => {
if (entry.members !== undefined || entry.linearTeamId !== undefined) {
// Already a group — recurse into members
if (entry.members) {
entry.members = normalizeLegacyAllowlist(entry.members);
}
return entry as AllowlistGroup;
}
// Legacy flat entry — treat as leaf, preserve all existing fields
return entry as AllowlistLeaf;
});
}

/**
* Validate configuration structure and required fields.
*/
function validateConfig(config: Config): void {
const errors: string[] = [];

// Validate protected labels
// Protected labels
if (!config.protectedLabels || !Array.isArray(config.protectedLabels)) {
errors.push('protectedLabels must be an array');
} else if (config.protectedLabels.length === 0) {
errors.push('protectedLabels must contain at least one label name');
}

// Validate protected fields
// Protected fields
if (!config.protectedFields || typeof config.protectedFields !== 'object') {
errors.push('protectedFields must be an object');
}

// Validate allowlist
// Allowlist — hierarchical validation
if (!config.allowlist || !Array.isArray(config.allowlist)) {
errors.push('allowlist must be an array');
} else if (config.allowlist.length === 0) {
errors.push('allowlist must contain at least one user');
errors.push('allowlist must contain at least one entry');
} else {
config.allowlist.forEach((user, index) => {
if (!user.email && !user.id) {
errors.push(`allowlist[${index}] must have either email or id`);
}
config.allowlist.forEach((entry, i) => {
validateAllowlistEntry(entry, `allowlist[${i}]`, 0, errors);
});
}

// Validate agent config
// Agent config
if (!config.agent || !config.agent.name || !config.agent.identifier) {
errors.push('agent must have name and identifier');
}

// Validate slack config
// Slack config
if (!config.slack || typeof config.slack.enabled !== 'boolean') {
errors.push('slack must have enabled boolean');
}
if (config.slack?.enabled && !config.slack.channelId) {
errors.push('slack.channelId is required when slack is enabled');
}

// Validate behavior config
// Behavior config
if (!config.behavior || typeof config.behavior !== 'object') {
errors.push('behavior configuration is required');
}

// Validate logging config
// Logging config
if (!config.logging || !config.logging.level) {
errors.push('logging configuration is required');
}
Expand All @@ -90,7 +119,62 @@ function validateConfig(config: Config): void {
}

/**
* Validate environment variables
* Recursively validate an allowlist entry.
* Detects cycles via the path argument (same group name appearing twice in
* the same ancestor chain).
*/
function validateAllowlistEntry(
entry: AllowlistEntry,
path: string,
depth: number,
errors: string[]
): void {
if (depth > MAX_ALLOWLIST_DEPTH) {
errors.push(`${path}: allowlist nesting exceeds maximum depth of ${MAX_ALLOWLIST_DEPTH}`);
return;
}

// Validate permissions if present
if ('permissions' in entry && entry.permissions !== undefined) {
if (!Array.isArray(entry.permissions)) {
errors.push(`${path}.permissions must be an array`);
} else {
entry.permissions.forEach((p, i) => {
if (!VALID_PERMISSIONS.has(p as Permission)) {
errors.push(
`${path}.permissions[${i}]: "${p}" is not a valid permission. ` +
`Valid values: ${Array.from(VALID_PERMISSIONS).join(', ')}`
);
}
});
}
}

if (isAllowlistGroup(entry)) {
const group = entry as AllowlistGroup;

if (!group.name) {
errors.push(`${path}: group entry must have a name`);
}

if (!group.linearTeamId && (!group.members || group.members.length === 0)) {
errors.push(`${path} ("${group.name}"): group must have at least one member or a linearTeamId`);
}

(group.members ?? []).forEach((member, i) => {
validateAllowlistEntry(member, `${path}.members[${i}]`, depth + 1, errors);
});
} else {
const leaf = entry as AllowlistLeaf;

if (!leaf.email && !leaf.id) {
errors.push(`${path}: leaf entry must have either email or id`);
}
}
}

/**
* Validate environment variables.
*/
export function validateEnvironment(): void {
const required = ['LINEAR_API_KEY'];
Expand All @@ -103,9 +187,7 @@ export function validateEnvironment(): void {
);
}

// Warn about optional but recommended vars
if (!process.env.LINEAR_WEBHOOK_SECRET) {
console.warn('⚠️ LINEAR_WEBHOOK_SECRET not set. Webhook signature verification will be skipped (not recommended for production).');
}
}

Loading