-
Notifications
You must be signed in to change notification settings - Fork 107
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: introduce session and authentication strategies
- Loading branch information
Showing
2 changed files
with
347 additions
and
0 deletions.
There are no files selected for viewing
160 changes: 160 additions & 0 deletions
160
packages/services/api/src/modules/auth/lib/authz.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,160 @@ | ||
import { HiveError } from '../../../shared/errors'; | ||
import { AuthorizationPolicyStatement, Session } from './authz'; | ||
|
||
class TestSession extends Session { | ||
policyStatements: Array<AuthorizationPolicyStatement>; | ||
constructor(policyStatements: Array<AuthorizationPolicyStatement>) { | ||
super(); | ||
this.policyStatements = policyStatements; | ||
} | ||
|
||
public loadPolicyStatementsForOrganization( | ||
_: string, | ||
): Promise<Array<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement> { | ||
return this.policyStatements; | ||
} | ||
} | ||
|
||
describe('Session.assertPerformAction', () => { | ||
test('No policies results in rejection', async () => { | ||
const session = new TestSession([]); | ||
const result = await session | ||
.assertPerformAction({ | ||
organizationId: '50b84370-49fc-48d4-87cb-bde5a3c8fd2f', | ||
resourceType: 'target', | ||
resourceId: '50b84370-49fc-48d4-87cb-bde5a3c8fd2f', | ||
action: 'target:view', | ||
}) | ||
.catch(error => error); | ||
expect(result).toBeInstanceOf(HiveError); | ||
}); | ||
test('Single allow policy on specific resource allows action', async () => { | ||
const session = new TestSession([ | ||
{ | ||
effect: 'allow', | ||
resource: | ||
'hrn:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:target/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', | ||
action: 'target:view', | ||
}, | ||
]); | ||
const result = await session | ||
.assertPerformAction({ | ||
organizationId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', | ||
resourceType: 'target', | ||
resourceId: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', | ||
action: 'target:view', | ||
}) | ||
.catch(error => error); | ||
expect(result).toEqual(undefined); | ||
}); | ||
test('Single policy on wildcard resource id allows action', async () => { | ||
const session = new TestSession([ | ||
{ | ||
effect: 'allow', | ||
resource: 'hrn:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:target/*', | ||
action: 'target:view', | ||
}, | ||
]); | ||
const result = await session | ||
.assertPerformAction({ | ||
organizationId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', | ||
resourceType: 'target', | ||
resourceId: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', | ||
action: 'target:view', | ||
}) | ||
.catch(error => error); | ||
expect(result).toEqual(undefined); | ||
}); | ||
test('Single policy on wildcard organization allows action', async () => { | ||
const session = new TestSession([ | ||
{ | ||
effect: 'allow', | ||
resource: 'hrn:*:target/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', | ||
action: 'target:view', | ||
}, | ||
]); | ||
const result = await session | ||
.assertPerformAction({ | ||
organizationId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', | ||
resourceType: 'target', | ||
resourceId: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', | ||
action: 'target:view', | ||
}) | ||
.catch(error => error); | ||
expect(result).toEqual(undefined); | ||
}); | ||
test('Single policy on wildcard organization and resource id allows action', async () => { | ||
const session = new TestSession([ | ||
{ | ||
effect: 'allow', | ||
resource: 'hrn:*:target/*', | ||
action: 'target:view', | ||
}, | ||
]); | ||
const result = await session | ||
.assertPerformAction({ | ||
organizationId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', | ||
resourceType: 'target', | ||
resourceId: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', | ||
action: 'target:view', | ||
}) | ||
.catch(error => error); | ||
expect(result).toEqual(undefined); | ||
}); | ||
test('Single policy on wildcard resource allows action', async () => { | ||
const session = new TestSession([ | ||
{ | ||
effect: 'allow', | ||
resource: 'hrn:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:*', | ||
action: 'target:view', | ||
}, | ||
]); | ||
await session.assertPerformAction({ | ||
organizationId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', | ||
resourceType: 'target', | ||
resourceId: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', | ||
action: 'target:view', | ||
}); | ||
}); | ||
test('Single policy on different organization disallows action', async () => { | ||
const session = new TestSession([ | ||
{ | ||
effect: 'allow', | ||
resource: 'hrn:cccccccc-cccc-cccc-cccc-cccccccccccc:*', | ||
action: 'target:view', | ||
}, | ||
]); | ||
const result = await session | ||
.assertPerformAction({ | ||
organizationId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', | ||
resourceType: 'target', | ||
resourceId: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', | ||
action: 'target:view', | ||
}) | ||
.catch(error => error); | ||
expect(result).toBeInstanceOf(HiveError); | ||
}); | ||
test('A single deny policy always disallows action', async () => { | ||
const session = new TestSession([ | ||
{ | ||
effect: 'allow', | ||
resource: 'hrn:*:*', | ||
action: 'target:view', | ||
}, | ||
{ | ||
effect: 'deny', | ||
resource: 'hrn:*:*', | ||
action: 'target:view', | ||
}, | ||
]); | ||
const result = await session | ||
.assertPerformAction({ | ||
organizationId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', | ||
resourceType: 'target', | ||
resourceId: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', | ||
action: 'target:view', | ||
}) | ||
.catch(error => error); | ||
expect(result).toBeInstanceOf(HiveError); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,187 @@ | ||
import { FastifyReply } from 'fastify'; | ||
import { FastifyRequest } from '@hive/service-common'; | ||
import { HiveError } from '../../../shared/errors'; | ||
import { isUUID } from '../../../shared/is-uuid'; | ||
|
||
export type AuthorizationPolicyStatement = { | ||
effect: 'allow' | 'deny'; | ||
action: string | string[]; | ||
resource: string | string[]; | ||
}; | ||
|
||
/** | ||
* Parses a Hive Resource identifier into an object | ||
* e.g. `"hrn:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:target/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"` | ||
* becomes | ||
* ```json | ||
* { | ||
* "organizationId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", | ||
* "resourceType": "target", | ||
* "resourceId": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" | ||
* } | ||
* ``` | ||
*/ | ||
function parseResourceIdentifier(resource: string) { | ||
const parts = resource.split(':'); | ||
if (parts.length < 2) { | ||
throw new Error('Invalid resource identifier (1)'); | ||
} | ||
if (parts[0] !== 'hrn') { | ||
throw new Error('Invalid resource identifier. Expected string to start with hrn: (2)'); | ||
} | ||
|
||
if (!parts[1] || (!isUUID(parts[1]) && parts[1] !== '*')) { | ||
throw new Error('Invalid resource identifier. Expected UUID or * (3)'); | ||
} | ||
const organizationId = parts[1]; | ||
if (!parts[2]) { | ||
throw new Error('Invalid resource identifier. Expected type or * (4)'); | ||
} | ||
|
||
const resourceParts = parts[2].split('/'); | ||
const resourceType = resourceParts[0]; | ||
const resourceId = resourceParts.at(1) ?? null; | ||
|
||
return { organizationId, resourceType, resourceId }; | ||
} | ||
|
||
/** | ||
* Abstract session class that is implemented by various ways to identify a session. | ||
* A session is a way to identify a user and their permissions for a specific organization. | ||
* | ||
* The `Session.loadPolicyStatementsForOrganization` method must be implemented by the subclass. | ||
*/ | ||
export abstract class Session { | ||
/** Load policy statements for a specific organization. */ | ||
protected abstract loadPolicyStatementsForOrganization( | ||
organizationId: string, | ||
): Promise<Array<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement>; | ||
|
||
/** | ||
* Check whether a session is allowed to perform a specific action. | ||
* Throws a HiveError if the action is not allowed. | ||
*/ | ||
public async assertPerformAction(args: { | ||
organizationId: string; | ||
resourceType: 'target' | 'project' | 'organization'; | ||
resourceId: string | null; | ||
action: `${string}:${string}`; | ||
}): Promise<void> { | ||
const permissions = await this.loadPolicyStatementsForOrganization(args.organizationId); | ||
const [actionScope] = args.action.split(':'); | ||
|
||
let isAllowed = false; | ||
|
||
for (const permission of permissions) { | ||
const parsedResources = ( | ||
Array.isArray(permission.resource) ? permission.resource : [permission.resource] | ||
).map(parseResourceIdentifier); | ||
|
||
let didMatchResource = false; | ||
|
||
// check if resource matches | ||
for (const resource of parsedResources) { | ||
// if org is not the same, skip | ||
if (resource.organizationId !== '*' && resource.organizationId !== args.organizationId) { | ||
continue; | ||
} | ||
|
||
// if resource type is not the same, skip | ||
if (resource.resourceType !== '*' && resource.resourceType !== args.resourceType) { | ||
continue; | ||
} | ||
|
||
if ( | ||
args.resourceId && | ||
resource.resourceType !== '*' && | ||
resource.resourceId !== '*' && | ||
args.resourceId !== resource.resourceId | ||
) { | ||
continue; | ||
} | ||
|
||
didMatchResource = true; | ||
} | ||
|
||
if (!didMatchResource) { | ||
continue; | ||
} | ||
|
||
// check if action matches | ||
const actions = Array.isArray(permission.action) ? permission.action : [permission.action]; | ||
for (const action of actions) { | ||
if ( | ||
// any action | ||
action === '*' || | ||
// exact action | ||
args.action === action || | ||
// scope:* | ||
(actionScope === action.split(':')[0] && action.split(':')[1] === '*') | ||
) { | ||
if (permission.effect === 'deny') { | ||
throw new HiveError('Permission denied.'); | ||
} else { | ||
isAllowed = true; | ||
} | ||
} | ||
} | ||
} | ||
|
||
if (!isAllowed) { | ||
throw new HiveError('Permission denied.'); | ||
} | ||
} | ||
} | ||
|
||
/** Unauthenticated session that is returned by default. */ | ||
class UnauthenticatedSession extends Session { | ||
protected loadPolicyStatementsForOrganization( | ||
_: string, | ||
): Promise<Array<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement> { | ||
return []; | ||
} | ||
} | ||
|
||
/** | ||
* Strategy to authenticate a session from an incoming request. | ||
* E.g. SuperTokens, JWT, etc. | ||
*/ | ||
abstract class AuthNStrategy<TSession extends Session> { | ||
/** | ||
* Parse a session from an incoming request. | ||
* Returns null if the strategy does not apply to the request. | ||
* Returns a session if the strategy applies to the request. | ||
* Rejects if the strategy applies to the request but the session could not be parsed. | ||
*/ | ||
public abstract parse(args: { | ||
req: FastifyRequest; | ||
reply: FastifyReply; | ||
}): Promise<TSession | null>; | ||
} | ||
|
||
/** Helper class to Authenticate an incoming request. */ | ||
export class AuthN { | ||
private strategies: Array<AuthNStrategy<Session>>; | ||
|
||
constructor(deps: { | ||
/** List of strategies for authentication a user */ | ||
strategies: Array<AuthNStrategy<Session>>; | ||
}) { | ||
this.strategies = deps.strategies; | ||
} | ||
|
||
/** | ||
* Returns the first successful `Session` created by a authentication strategy. | ||
* If no authentication strategy succeeds a `UnauthenticatedSession` is returned instead. | ||
*/ | ||
async authenticate(args: { req: FastifyRequest; reply: FastifyReply }): Promise<Session> { | ||
for (const strategy of this.strategies) { | ||
const session = await strategy.parse(args); | ||
if (session) { | ||
return session; | ||
} | ||
} | ||
|
||
return new UnauthenticatedSession(); | ||
} | ||
} |