Skip to content
Open
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
1 change: 1 addition & 0 deletions src/proxy/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const branchPushChain: ProcessorExec[] = [
];

const tagPushChain: ProcessorExec[] = [
proc.push.resolveUserFromToken,
proc.push.checkRepoInAuthorisedList,
proc.push.checkUserPushPermission,
proc.push.checkIfWaitingAuth,
Expand Down
9 changes: 6 additions & 3 deletions src/proxy/processors/push-action/resolveUserFromToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,11 @@ async function exec(req: Request, action: Action): Promise<Action> {

const cached = scmTokenCache.lookup(provider.name, token);
if (cached) {
step.log(`${provider.name}: resolved push identity from cache: ${cached}`);
action.user = cached;
step.log(`${provider.name}: resolved push identity from cache: ${cached.username}`);
action.user = cached.username;
if (cached.email) {
action.userEmail = cached.email;
}
action.addStep(step);
return action;
}
Expand All @@ -97,7 +100,7 @@ async function exec(req: Request, action: Action): Promise<Action> {
);
action.user = user.username;
action.userEmail = user.email;
scmTokenCache.store(provider.name, token, user.username);
scmTokenCache.store(provider.name, token, user.username, user.email);
} else {
step.log(
`No git-proxy user has gitAccount '${identity.login}' — ` +
Expand Down
17 changes: 12 additions & 5 deletions src/proxy/processors/push-action/tokenIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ export type ScmUserInfo = {
login: string;
};

type CacheEntry = { username: string; provider: string; cachedAt: number };
export type CachedIdentity = { username: string; email: string | null };

type CacheEntry = CachedIdentity & { provider: string; cachedAt: number };
// 7 days — PATs are rarely rotated more frequently than this in practice; the cache is a
// rate-limit optimization only (keys are one-way SHA-512 hashes, not recoverable tokens).
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
Expand All @@ -37,7 +39,7 @@ export class ScmTokenCache {
return crypto.createHash('sha512').update(`${provider}:${token}`).digest('hex');
}

lookup(provider: string, token: string): string | null {
lookup(provider: string, token: string): CachedIdentity | null {
const k = this.key(provider, token);
const entry = this.cache.get(k);
if (!entry) return null;
Expand All @@ -46,11 +48,16 @@ export class ScmTokenCache {
return null;
}
entry.cachedAt = Date.now();
return entry.username;
return { username: entry.username, email: entry.email ?? null };
}

store(provider: string, token: string, username: string): void {
this.cache.set(this.key(provider, token), { username, provider, cachedAt: Date.now() });
store(provider: string, token: string, username: string, email: string | null = null): void {
this.cache.set(this.key(provider, token), {
username,
email,
provider,
cachedAt: Date.now(),
});
}

evictByUsername(provider: string, username: string): void {
Expand Down
8 changes: 8 additions & 0 deletions test/chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,14 @@ describe('proxy chain', function () {
expect(pullChain).toEqual(chain.pullActionChain);
});

it('runs resolveUserFromToken before checkUserPushPermission on tag pushes', () => {
const tag = chain.tagPushChain;
const resolveIdx = tag.indexOf(processors.push.resolveUserFromToken);
const permIdx = tag.indexOf(processors.push.checkUserPushPermission);
expect(resolveIdx).toBeGreaterThanOrEqual(0);
expect(permIdx).toBeGreaterThan(resolveIdx);
});

it('returns tagPushChain when action.type is push and action.actionType is TAG', async () => {
const action = new Action(
'2',
Expand Down
44 changes: 39 additions & 5 deletions test/processors/resolveUserFromToken.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,16 @@ describe('ScmTokenCache', () => {
it('should return username on cache hit', () => {
const cache = new ScmTokenCache();
cache.store('github', 'sometoken', 'octocat');
expect(cache.lookup('github', 'sometoken')).toBe('octocat');
expect(cache.lookup('github', 'sometoken')).toEqual({ username: 'octocat', email: null });
});

it('should return stored email on cache hit', () => {
const cache = new ScmTokenCache();
cache.store('github', 'sometoken', 'octocat', 'octocat@github.com');
expect(cache.lookup('github', 'sometoken')).toEqual({
username: 'octocat',
email: 'octocat@github.com',
});
});

it('should return null after TTL expires', () => {
Expand All @@ -153,10 +162,10 @@ describe('ScmTokenCache', () => {
provider: 'github',
cachedAt: Date.now() - 90,
});
expect(cache.lookup('github', 'sometoken')).toBe('octocat'); // hit resets cachedAt
expect(cache.lookup('github', 'sometoken')).toEqual({ username: 'octocat', email: null }); // hit resets cachedAt
// backdate again to 90ms — if TTL had not been reset, this would be 180ms total (expired)
(cache as any).cache.get(key).cachedAt = Date.now() - 90;
expect(cache.lookup('github', 'sometoken')).toBe('octocat'); // still valid because TTL was reset
expect(cache.lookup('github', 'sometoken')).toEqual({ username: 'octocat', email: null }); // still valid because TTL was reset
});

it('should not share entries across providers', () => {
Expand All @@ -173,14 +182,14 @@ describe('ScmTokenCache', () => {
cache.evictByUsername('github', 'alice');
expect(cache.lookup('github', 'token1')).toBeNull();
expect(cache.lookup('github', 'token2')).toBeNull();
expect(cache.lookup('github', 'token3')).toBe('bob');
expect(cache.lookup('github', 'token3')).toEqual({ username: 'bob', email: null });
});

it('should not evict across providers', () => {
const cache = new ScmTokenCache();
cache.store('github', 'sometoken', 'alice');
cache.evictByUsername('gitlab', 'alice');
expect(cache.lookup('github', 'sometoken')).toBe('alice');
expect(cache.lookup('github', 'sometoken')).toEqual({ username: 'alice', email: null });
});
});

Expand Down Expand Up @@ -399,4 +408,29 @@ describe('resolveUserFromToken cache integration', () => {
expect(result.user).toBe('cached-user');
expect(fetchSpy).not.toHaveBeenCalled();
});

it('should set userEmail from cache so permission checks the pusher not the last committer', async () => {
vi.doMock('../../src/db', () => ({
findUserByGitAccount: vi.fn(),
}));
vi.doMock('../../src/proxy/processors/push-action/tokenIdentity', async () => {
const real = await vi.importActual<
typeof import('../../src/proxy/processors/push-action/tokenIdentity')
>('../../src/proxy/processors/push-action/tokenIdentity');
const cache = new real.ScmTokenCache();
cache.store('github', 'ghp_testtoken123', 'bob', 'bob@corp.example');
return { ...real, scmTokenCache: cache };
});
const mod = await import('../../src/proxy/processors/push-action/resolveUserFromToken');
const req = makeRequest();
const action = makeAction('https://github.com/finos/git-proxy.git');
action.user = 'eve';
action.userEmail = 'eve@corp.example';

const result = await mod.exec(req, action);

expect(result.user).toBe('bob');
expect(result.userEmail).toBe('bob@corp.example');
expect(fetchSpy).not.toHaveBeenCalled();
});
});
Loading