-
Notifications
You must be signed in to change notification settings - Fork 121
feat: organization access tokens #6493
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
35cfca6
83daaeb
cfb17b7
d68fd71
1e73473
563c69b
0adf265
5ad7426
3deed76
66165f3
faa9452
68cf46e
440121f
1587b4c
58fc5bf
1ff1081
60ab85b
acb9e30
90c68ca
fa7e965
90434ed
fae1421
1b17d26
8f6096a
222491b
f3ee9f3
67da178
766102f
6109e97
cc7c340
8c38327
b98959d
96abc14
1a28781
346f2d2
e9959eb
f4a8679
8ca2c4a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -132,7 +132,8 @@ | |
"countup.js": "patches/countup.js.patch", | ||
"@oclif/[email protected]": "patches/@[email protected]", | ||
"@fastify/vite": "patches/@fastify__vite.patch", | ||
"[email protected]": "patches/[email protected]" | ||
"[email protected]": "patches/[email protected]", | ||
"bentocache": "patches/bentocache.patch" | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
import { type MigrationExecutor } from '../pg-migrator'; | ||
|
||
export default { | ||
name: '2025.02.20T00-00-00.organization-access-tokens.ts', | ||
run: ({ sql }) => sql` | ||
CREATE TABLE IF NOT EXISTS "organization_access_tokens" ( | ||
"id" UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4() | ||
, "organization_id" UUID NOT NULL REFERENCES "organizations" ("id") ON DELETE CASCADE | ||
, "created_at" timestamptz NOT NULL DEFAULT now() | ||
, "title" text NOT NULL | ||
, "description" text NOT NULL | ||
, "permissions" text[] NOT NULL | ||
, "assigned_resources" jsonb | ||
, "hash" text NOT NULL | ||
, "first_characters" text NOT NULL | ||
); | ||
|
||
CREATE INDEX IF NOT EXISTS "organization_access_tokens_organization_id" ON "organization_access_tokens" ( | ||
"organization_id" | ||
, "created_at" DESC | ||
, "id" DESC | ||
); | ||
`, | ||
} satisfies MigrationExecutor; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
import * as crypto from 'node:crypto'; | ||
import { type FastifyReply, type FastifyRequest } from '@hive/service-common'; | ||
import * as OrganizationAccessKey from '../../organization/lib/organization-access-key'; | ||
import { OrganizationAccessTokensCache } from '../../organization/providers/organization-access-tokens-cache'; | ||
import { Logger } from '../../shared/providers/logger'; | ||
import { OrganizationAccessTokenValidationCache } from '../providers/organization-access-token-validation-cache'; | ||
import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz'; | ||
|
||
function hashToken(token: string) { | ||
return crypto.createHash('sha256').update(token).digest('hex'); | ||
} | ||
|
||
export class OrganizationAccessTokenSession extends Session { | ||
public readonly organizationId: string; | ||
private policies: Array<AuthorizationPolicyStatement>; | ||
|
||
constructor( | ||
args: { | ||
organizationId: string; | ||
policies: Array<AuthorizationPolicyStatement>; | ||
}, | ||
deps: { | ||
logger: Logger; | ||
}, | ||
) { | ||
super({ logger: deps.logger }); | ||
this.organizationId = args.organizationId; | ||
this.policies = args.policies; | ||
} | ||
|
||
protected loadPolicyStatementsForOrganization( | ||
_: string, | ||
): Promise<Array<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement> { | ||
return this.policies; | ||
} | ||
} | ||
|
||
export class OrganizationAccessTokenStrategy extends AuthNStrategy<OrganizationAccessTokenSession> { | ||
private logger: Logger; | ||
|
||
private organizationAccessTokenCache: OrganizationAccessTokensCache; | ||
private organizationAccessTokenValidationCache: OrganizationAccessTokenValidationCache; | ||
|
||
constructor(deps: { | ||
logger: Logger; | ||
organizationAccessTokensCache: OrganizationAccessTokensCache; | ||
organizationAccessTokenValidationCache: OrganizationAccessTokenValidationCache; | ||
}) { | ||
super(); | ||
this.logger = deps.logger.child({ module: 'OrganizationAccessTokenStrategy' }); | ||
this.organizationAccessTokenCache = deps.organizationAccessTokensCache; | ||
this.organizationAccessTokenValidationCache = deps.organizationAccessTokenValidationCache; | ||
} | ||
|
||
async parse(args: { | ||
req: FastifyRequest; | ||
reply: FastifyReply; | ||
}): Promise<OrganizationAccessTokenSession | null> { | ||
this.logger.debug('Attempt to resolve an API token from headers'); | ||
let value: string | null = null; | ||
for (const headerName in args.req.headers) { | ||
if (headerName.toLowerCase() !== 'authorization') { | ||
continue; | ||
} | ||
const values = args.req.headers[headerName]; | ||
value = (Array.isArray(values) ? values.at(0) : values) ?? null; | ||
} | ||
|
||
if (!value) { | ||
this.logger.debug('No access token header found.'); | ||
return null; | ||
} | ||
|
||
if (!value.startsWith('Bearer ')) { | ||
this.logger.debug('Access token does not start with "Bearer ".'); | ||
return null; | ||
} | ||
|
||
const accessToken = value.replace('Bearer ', ''); | ||
const result = OrganizationAccessKey.decode(accessToken); | ||
if (result.type === 'error') { | ||
this.logger.debug(result.reason); | ||
return null; | ||
} | ||
|
||
const organizationAccessToken = await this.organizationAccessTokenCache.get( | ||
result.accessKey.id, | ||
); | ||
if (!organizationAccessToken) { | ||
return null; | ||
} | ||
|
||
// let's hash it so we do not store the plain private key in memory | ||
const key = hashToken(accessToken); | ||
const isHashMatch = await this.organizationAccessTokenValidationCache.getOrSetForever({ | ||
factory: () => | ||
OrganizationAccessKey.verify(result.accessKey.privateKey, organizationAccessToken.hash), | ||
key, | ||
}); | ||
|
||
if (!isHashMatch) { | ||
this.logger.debug('Provided private key does not match hash.'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of returning null, do we want to capture this case to return more specific error messages? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, I think we do not really want to tell a potential brute-forcer that he correctly guessed the access key id, but not the private key part? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good point. I picked a condition at random to add this comment to. But more generically for any of these null cases -- would it be worth providing info to the user? E.g. I suspect still the answer is no, but wanted to make sure i wasnt missing something. |
||
return null; | ||
} | ||
|
||
return new OrganizationAccessTokenSession( | ||
{ | ||
organizationId: organizationAccessToken.organizationId, | ||
policies: organizationAccessToken.authorizationPolicyStatements, | ||
}, | ||
{ | ||
logger: args.req.log, | ||
}, | ||
); | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.