Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1912bd6
chore: merge release v3.59.1 back to main [skip ci]
github-actions[bot] May 19, 2026
5e15a73
feat(people): add employment events tracking and offboarding checklist
Marfuen May 20, 2026
e41365d
feat(frameworks): show controls as default tab with requirement column
github-actions[bot] May 20, 2026
200b112
fix(people): ds component compatibility fixes for offboarding UI
github-actions[bot] May 20, 2026
07f02e4
fix(evidence-export): load automations one at a time to prevent OOM
github-actions[bot] May 20, 2026
26e53da
feat(api): unblock cloud-tests mutations for API key + service token …
tofikwest May 20, 2026
e2ed683
Merge pull request #2883 from trycompai/tofik/api-key-cloud-tests-mut…
tofikwest May 20, 2026
9d43a6b
fix(people): address cubic review findings for offboarding feature (#…
Marfuen May 20, 2026
e0ec0f7
feat(cloud-tests): add deterministic AWS plan normalizer for SLR params
tofikwest May 21, 2026
5f2d342
feat(cloud-tests): fail fast on missing required AWS command params
tofikwest May 21, 2026
90c95f6
fix(cloud-tests): show meaningful Auto-Remediate diff for configure-o…
tofikwest May 21, 2026
34b5dc7
docs(cloud-tests): add SLR AWSServiceName mapping to the remediation …
tofikwest May 21, 2026
b9d08c8
fix(ui): close MultipleSelector dropdown on blur so it stops blocking…
tofikwest May 21, 2026
d71375a
chore: merge release v3.59.2 back to main [skip ci]
github-actions[bot] May 21, 2026
8adf505
feat(cloud-tests): universal AI step-repair on AWS validation errors
tofikwest May 21, 2026
5cffaff
Merge branch 'main' into tofik/fix-auto-remediate-null-params
tofikwest May 21, 2026
eb52c8d
Merge pull request #2885 from trycompai/tofik/fix-auto-remediate-null…
tofikwest May 21, 2026
0bf5dee
Merge branch 'main' into tofik/fix-github-multiselect-severity-dropdown
tofikwest May 21, 2026
b837dfb
Merge pull request #2886 from trycompai/tofik/fix-github-multiselect-…
tofikwest May 21, 2026
8026352
fix(people): address remaining cubic review findings for offboarding …
Marfuen May 21, 2026
8ec5214
fix(people): address third round of cubic review findings (#2891)
Marfuen May 21, 2026
ca9d9a5
fix(people): address fourth round of cubic review findings (#2892)
Marfuen May 21, 2026
dbc364c
fix(people): address fifth round of cubic review findings (#2893)
Marfuen May 21, 2026
080b1f9
chore(people): address sixth round of cubic review findings (#2894)
Marfuen May 21, 2026
b8bf01d
chore(people): fix offboarding test spec missing mocks (#2895)
Marfuen May 21, 2026
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
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { AdminFeatureFlagsModule } from './admin-feature-flags/admin-feature-fla
import { TimelinesModule } from './timelines/timelines.module';
import { BackgroundChecksModule } from './background-checks/background-checks.module';
import { BillingModule } from './billing/billing.module';
import { OffboardingChecklistModule } from './offboarding-checklist/offboarding-checklist.module';

@Module({
imports: [
Expand Down Expand Up @@ -122,6 +123,7 @@ import { BillingModule } from './billing/billing.module';
AdminOrganizationsModule,
AdminFeatureFlagsModule,
TimelinesModule,
OffboardingChecklistModule,
],
controllers: [AppController],
providers: [
Expand Down
208 changes: 208 additions & 0 deletions apps/api/src/auth/acting-user.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// Mock @db before importing the service so the Prisma client doesn't try
// to connect at import time in this unit-test env.
const mockDb = {
member: {
findFirst: jest.fn(),
},
};

jest.mock('@db', () => ({ db: mockDb }));

import { ActingUserResolver } from './acting-user.service';
import type { AuthenticatedRequest } from './types';

function makeReq(overrides: Partial<AuthenticatedRequest> = {}): AuthenticatedRequest {
return {
organizationId: 'org_1',
authType: 'session',
isApiKey: false,
isServiceToken: false,
isPlatformAdmin: false,
userRoles: null,
...overrides,
} as unknown as AuthenticatedRequest;
}

describe('ActingUserResolver', () => {
let resolver: ActingUserResolver;

beforeEach(() => {
jest.clearAllMocks();
resolver = new ActingUserResolver();
});

describe('session caller (short-circuit)', () => {
it('returns req.userId without a DB query', async () => {
const req = makeReq({
userId: 'usr_session_alice',
authType: 'session',
});

const result = await resolver.resolve(req, 'org_1');

expect(result).toEqual({
userId: 'usr_session_alice',
source: 'session',
});
// Critical regression guard — session auth must NEVER hit the DB
// for owner lookup. That would be a perf regression on every UI call.
expect(mockDb.member.findFirst).not.toHaveBeenCalled();
});

it('does NOT include a callerLabel for session callers', async () => {
// Session actions don't need automation-marker text in the audit log.
const req = makeReq({ userId: 'usr_session_alice' });
const result = await resolver.resolve(req, 'org_1');
expect(result.callerLabel).toBeUndefined();
});
});

describe('service token acting on behalf of a specific user', () => {
it('returns the x-user-id userId set by HybridAuthGuard, source service-token-acting', async () => {
// HybridAuthGuard already validated the x-user-id header against
// Member and set req.userId. We classify it differently from session
// for telemetry but don't need to re-validate.
const req = makeReq({
userId: 'usr_acting_bob',
authType: 'service',
isApiKey: false,
isServiceToken: true,
serviceName: 'Trigger.dev',
});

const result = await resolver.resolve(req, 'org_1');

expect(result).toEqual({
userId: 'usr_acting_bob',
source: 'service-token-acting',
});
expect(mockDb.member.findFirst).not.toHaveBeenCalled();
});
});

describe('API key caller (owner fallback)', () => {
it('resolves to the org owner and labels the caller for the audit log', async () => {
mockDb.member.findFirst.mockResolvedValueOnce({
userId: 'usr_owner_carol',
});

const req = makeReq({
userId: undefined,
authType: 'api-key',
isApiKey: true,
apiKeyId: 'apk_1',
apiKeyName: 'CI Pipeline',
});

const result = await resolver.resolve(req, 'org_1');

expect(result.userId).toBe('usr_owner_carol');
expect(result.source).toBe('org-owner-fallback');
expect(result.callerLabel).toBe('via API key "CI Pipeline"');
});

it('scopes the owner lookup to the calling org (cross-tenant safety)', async () => {
mockDb.member.findFirst.mockResolvedValueOnce({ userId: 'usr_owner' });
const req = makeReq({
userId: undefined,
authType: 'api-key',
isApiKey: true,
apiKeyName: 'X',
});

await resolver.resolve(req, 'org_target');

expect(mockDb.member.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
organizationId: 'org_target',
role: { contains: 'owner' },
}),
}),
);
});

it('picks the OLDEST owner deterministically (orderBy createdAt asc)', async () => {
// Determinism matters — re-running the same automation should always
// attribute to the same user, even if newer owners are added/removed.
mockDb.member.findFirst.mockResolvedValueOnce({ userId: 'usr_oldest' });
const req = makeReq({
userId: undefined,
isApiKey: true,
apiKeyName: 'X',
});

await resolver.resolve(req, 'org_1');

expect(mockDb.member.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: { createdAt: 'asc' },
}),
);
});

it('returns null userId (not throw) when the org has no owner-role members', async () => {
// Soft failure — the controller surfaces a 400 with an actionable
// message ("ensure your org has an owner"). Throwing here would
// 500 instead, which is worse UX.
mockDb.member.findFirst.mockResolvedValueOnce(null);
const req = makeReq({
userId: undefined,
isApiKey: true,
apiKeyName: 'X',
});

const result = await resolver.resolve(req, 'org_no_owner');

expect(result.userId).toBeNull();
expect(result.source).toBe('org-owner-fallback');
// callerLabel still populated so the eventual 400 message can mention
// which API key tried (helpful in customer support).
expect(result.callerLabel).toBe('via API key "X"');
});

it('falls back to "via API key" when the key name is missing (defensive)', async () => {
mockDb.member.findFirst.mockResolvedValueOnce({ userId: 'usr_owner' });
const req = makeReq({
userId: undefined,
isApiKey: true,
apiKeyName: undefined,
});

const result = await resolver.resolve(req, 'org_1');

expect(result.callerLabel).toBe('via API key');
});
});

describe('service token without x-user-id (owner fallback)', () => {
it('resolves to the org owner with a service-flavored caller label', async () => {
mockDb.member.findFirst.mockResolvedValueOnce({ userId: 'usr_owner' });
const req = makeReq({
userId: undefined,
authType: 'service',
isServiceToken: true,
serviceName: 'Trigger.dev',
});

const result = await resolver.resolve(req, 'org_1');

expect(result.userId).toBe('usr_owner');
expect(result.source).toBe('org-owner-fallback');
expect(result.callerLabel).toBe('via service "Trigger.dev"');
});

it('falls back to "via service token" when the service name is missing', async () => {
mockDb.member.findFirst.mockResolvedValueOnce({ userId: 'usr_owner' });
const req = makeReq({
userId: undefined,
isServiceToken: true,
serviceName: undefined,
});

const result = await resolver.resolve(req, 'org_1');

expect(result.callerLabel).toBe('via service token');
});
});
});
140 changes: 140 additions & 0 deletions apps/api/src/auth/acting-user.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { Injectable, Logger } from '@nestjs/common';
import { db } from '@db';
import type { AuthenticatedRequest } from './types';

/**
* The auth flow that produced the userId we'll attribute a mutation to.
*
* - 'session' — req.userId set by better-auth from a session cookie / bearer token.
* - 'service-token-acting' — service token caller passed an `x-user-id` header
* which HybridAuthGuard validated against Member and set on req.userId.
* - 'org-owner-fallback' — API key (or service token without x-user-id)
* resolved to the org's oldest owner. This keeps mutations API-callable
* without forcing callers to manage user IDs themselves.
*/
export type ActingUserSource =
| 'session'
| 'service-token-acting'
| 'org-owner-fallback';

export interface ResolvedActingUser {
/** User ID to attribute the mutation to. Null only when no fallback was
* available (e.g. an org with zero owner-role members — caller should
* surface a 400 with an actionable message). */
userId: string | null;
source: ActingUserSource;
/** Short label for audit log descriptions. Only set when source is
* 'org-owner-fallback' — session and explicit service-token acting
* don't need to call out automation in the audit trail. */
callerLabel?: string;
}

/**
* Resolves the user that a mutation should be attributed to, accepting any
* supported auth method. This is the single, shared way for write endpoints
* to answer "whose userId should I record on this audit log / row?".
*
* Rules:
* 1. Session callers — `req.userId` is already set by HybridAuthGuard.
* We return it without a DB query (zero overhead for the common UI path).
* 2. Service tokens calling on behalf of a specific user — HybridAuthGuard
* sets `req.userId` from the `x-user-id` header after Member validation.
* Same short-circuit as session.
* 3. API keys, or service tokens without `x-user-id` — no per-user identity
* exists. We attribute to the org's OLDEST owner (deterministic + stable
* across deletes of newer owners). This is consistent with how 19+
* other places in the codebase already look up org owners
* (`Member.role.contains('owner')`).
*
* Returning null userId is a soft failure — callers must surface a 400 with
* the org-needs-an-owner message rather than 500-ing on a Prisma FK error.
*/
@Injectable()
export class ActingUserResolver {
private readonly logger = new Logger(ActingUserResolver.name);

async resolve(
req: AuthenticatedRequest,
organizationId: string,
): Promise<ResolvedActingUser> {
// Path 1 + 2 — session caller, or service token acting on behalf of a
// specific user. HybridAuthGuard already set req.userId for both, so we
// just classify which one and short-circuit. No DB query.
if (req.userId) {
return {
userId: req.userId,
source: req.isServiceToken ? 'service-token-acting' : 'session',
};
}

// Path 3 — fall back to the org's owner.
const ownerUserId = await this.findOrgOwnerUserId(organizationId);
if (!ownerUserId) {
// No owner found. Don't invent one — the caller should reject the
// mutation with a clear message so the customer can fix the role
// assignment themselves.
this.logger.warn(
`No owner-role member found for org ${organizationId}; mutation cannot be attributed.`,
);
return {
userId: null,
source: 'org-owner-fallback',
callerLabel: this.buildCallerLabel(req),
};
}

return {
userId: ownerUserId,
source: 'org-owner-fallback',
callerLabel: this.buildCallerLabel(req),
};
}

/**
* Find the oldest owner of an organization. Oldest is deterministic and
* stable: removing a recently-added owner doesn't change the attribution
* target, removing the oldest one just promotes the next one. Matches the
* pattern used elsewhere (e.g. tasks/task-notifier.service.ts).
*
* Member.role is a comma-separated string (e.g. "owner,admin"), so we use
* Prisma's `contains` filter — same query shape as the 19+ other owner
* lookups in this codebase.
*/
private async findOrgOwnerUserId(
organizationId: string,
): Promise<string | null> {
const owner = await db.member.findFirst({
where: {
organizationId,
role: { contains: 'owner' },
},
orderBy: { createdAt: 'asc' },
select: { userId: true },
});
return owner?.userId ?? null;
}

/**
* Produces the short string that downstream audit-log descriptions
* prepend (e.g. `[via API key "CI Pipeline"]`). The caller decides where
* to put it; this helper just standardises the wording.
*
* Returns 'via API key' / 'via service token' / 'via API' as a graceful
* fallback so we always emit SOMETHING rather than drop attribution.
*/
private buildCallerLabel(req: AuthenticatedRequest): string {
if (req.isApiKey) {
return req.apiKeyName
? `via API key "${req.apiKeyName}"`
: 'via API key';
}
if (req.isServiceToken) {
return req.serviceName
? `via service "${req.serviceName}"`
: 'via service token';
}
// Should never reach here — Path 1/2 would have short-circuited — but
// we return a sane default rather than throw.
return 'via API';
}
}
Loading
Loading