A flexible, policy-based authorization system inspired by AWS IAM, designed for the Leo Platform. This SDK provides a robust way to control access to resources using identity-based policies with support for conditions, wildcards, and context variables.
- Overview
- How It Works
- Configuration
- Core Concepts
- Usage Examples
- Creating Auth Rules
- Policy Structure
- Conditions
- Context Variables
- Bootstrap Mode
- API Reference
The Leo Auth SDK provides a policy-based authorization system that allows you to:
- Define granular access controls for resources
- Support multiple identities and roles per user
- Use wildcards and pattern matching for flexible policies
- Apply conditional logic based on IP addresses, context variables, and more
- Store policies in DynamoDB or define them in code
The Leo Auth system works like a security guard that checks if a user can do something with a resource:
-
User Lookup: When a request comes in, the system first looks up the user by their Cognito Identity ID from the
LeoAuthUserDynamoDB table. Users have:- An identity ID (like a username)
- A list of roles/identities they belong to (like "admin", "user", "role/aws_key")
- Context information (like account IDs, custom metadata)
-
Policy Retrieval: The system then fetches all policies associated with the user's identities from the
LeoAuthDynamoDB table. Policies can also be pulled from a wildcard (*) identity that applies to everyone. -
Request Building: The request is structured with:
- An action (what they want to do, like "user:read" or "queue:write")
- A LRN (Leo Resource Name, similar to AWS ARN, like "lrn:leo:system:::resource")
- Context information (IP address, cognito details, custom fields)
-
Policy Evaluation: The system evaluates policies in two phases:
- Deny Phase: First, check if any policy explicitly denies the action. If so, reject immediately (deny always wins)
- Allow Phase: Then, check if any policy allows the action. If at least one allows it, grant access
-
Condition Checking: Within each policy, conditions can be specified (like IP address ranges, string matching, null checks). All conditions must pass for the policy to apply.
-
Decision: If no explicit deny and at least one allow, access is granted. Otherwise, access is denied.
User Request → getUser() → User Object with Identities
↓
authorize()
↓
Fetch Policies for all user identities (+ "*")
↓
Create Request Object
(action, LRN, context)
↓
Policy Validation
↓
┌─────────────┴─────────────┐
↓ ↓
Check DENY policies Check ALLOW policies
↓ ↓
Any deny matches? Any allow matches?
↓ ↓
DENIED ←─────YES YES─→ GRANTED
↓
NO → DENIED
The SDK automatically discovers configuration from multiple sources (checked in order):
- Environment Variables:
LEOAUTH,LEOAUTH_*(uppercase/lowercase with underscores) - Process/Global Objects:
process.leoauth,global.leoauth - Config Files: Looking up the directory tree for:
leoauth.config.json,leoauth.config.jsleoauthconfig.json,leoauthconfig.jsconfig/leoauth.config.json, etc.
- AWS Secrets Manager:
LEOAUTH_CONFIG_SECRETenvironment variable - leo-config package: Falls back to
config.leoauth,config.leo_auth, etc.
{
"LeoAuth": "YourAuthPoliciesTableName", // DynamoDB table with policies
"LeoAuthUser": "YourAuthUsersTableName" // DynamoDB table with users
}# Option 1: JSON string
export LEOAUTH='{"LeoAuth":"auth-table","LeoAuthUser":"users-table"}'
# Option 2: Individual variables
export LEOAUTH_LeoAuth="auth-table"
export LEOAUTH_LeoAuthUser="users-table"An identity is a role or group that a user belongs to. Users can have multiple identities:
"admin""role/readonly""team/engineering""*"(everyone)
A hierarchical resource identifier, similar to AWS ARNs:
Format: lrn:service:system:region:account:resource
Example: lrn:leo:rstreams:::queue/my-queue
What operation is being performed, typically in system:action format:
rstreams:readrstreams:writeuser:updatequeue:delete
Rules that define what actions are allowed or denied on which resources, optionally with conditions.
const leoAuth = require('leo-auth');
// In your Lambda function or API handler
async function handler(event) {
try {
// Get the user from the request
const user = await leoAuth.getUser(event);
// Authorize the user for a specific action
await user.authorize(event, {
lrn: 'lrn:leo:rstreams:::queue/my-queue',
action: 'read',
rstreams: {
queue: 'my-queue'
}
});
// If we get here, user is authorized
return {
statusCode: 200,
body: JSON.stringify({ message: 'Access granted' })
};
} catch (error) {
// Authorization failed
return {
statusCode: 403,
body: JSON.stringify({ message: 'Access Denied' })
};
}
}// Authorize access to a specific queue by ID
const queueId = 'user-notifications';
await user.authorize(event, {
lrn: 'lrn:leo:rstreams:::queue/{queueId}',
action: 'write',
rstreams: {
queueId: queueId // This replaces {queueId} in the LRN
}
});// Combines getUser and authorize in one call
const user = await leoAuth.authorize(event, {
lrn: 'lrn:leo:api:::user/{userId}',
action: 'update',
api: {
userId: '12345'
}
});// Pass context that can be checked in policy conditions
await user.authorize(event, {
lrn: 'lrn:leo:data:::account/{accountId}/records',
action: 'read',
data: {
accountId: '999'
},
context: ['account'] // Makes user's account context available to policies
});// Admin can act on behalf of another user by passing cognitoIdentityId in context
const event = {
body: JSON.stringify({
_context: {
cognitoIdentityId: 'user-to-impersonate'
},
// ... other data
}),
requestContext: {
identity: {
caller: 'admin-aws-key' // Admin using AWS key
}
}
};
const user = await leoAuth.getUser(event);
// User will be loaded as 'user-to-impersonate' instead of adminAuth rules are created by adding entries to the DynamoDB tables. Here's how to set up the authorization data:
{
"identity_id": "us-east-1:12345678-1234-1234-1234-123456789abc", // Primary Key
"identities": [
"role/developer",
"team/backend"
],
"context": {
"account": "999",
"department": "engineering"
}
}{
"identity": "role/developer", // Primary Key
"policies": {
"AllowReadQueues": [
"{\"Effect\":\"Allow\",\"Action\":\"rstreams:read\",\"Resource\":\"lrn:leo:rstreams:::queue/*\"}"
],
"AllowWriteOwnQueue": [
"{\"Effect\":\"Allow\",\"Action\":\"rstreams:write\",\"Resource\":\"lrn:leo:rstreams:::queue/${context.account}/*\",\"Condition\":{\"StringEquals\":{\"context:account\":\"${context.account}\"}}}"
]
}
}// Example: Add a new user with policies
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();
// Create a user
await dynamodb.put({
TableName: 'YourAuthUsersTable',
Item: {
identity_id: 'us-east-1:user-cognito-id',
identities: ['role/analyst', 'team/data'],
context: {
account: '12345',
region: 'us-west-2'
}
}
}).promise();
// Create policies for the role
await dynamodb.put({
TableName: 'YourAuthPoliciesTable',
Item: {
identity: 'role/analyst',
policies: {
ReadData: [
JSON.stringify({
Effect: 'Allow',
Action: 'data:read',
Resource: 'lrn:leo:data:::*'
})
],
NoDelete: [
JSON.stringify({
Effect: 'Deny',
Action: 'data:delete',
Resource: '*'
})
]
}
}
}).promise();
// Create a wildcard policy that applies to everyone
await dynamodb.put({
TableName: 'YourAuthPoliciesTable',
Item: {
identity: '*',
policies: {
BasicAccess: [
JSON.stringify({
Effect: 'Allow',
Action: 'system:health',
Resource: 'lrn:leo:system:::health'
})
]
}
}
}).promise();{
"Effect": "Allow",
"Action": "*",
"Resource": "lrn:leo:rstreams:::queue/my-queue"
}{
"Effect": "Allow",
"Action": "rstreams:*",
"Resource": "lrn:leo:rstreams:::queue/*"
}{
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"IpAddress": {
"aws:sourceip": ["10.0.0.0/8"]
}
}
}{
"Effect": "Allow",
"Action": "data:read",
"Resource": "lrn:leo:data:::account/${context.account}/*",
"Condition": {
"StringEquals": {
"context:account": "${context.account}"
}
}
}Policies are JSON objects with the following fields:
{
"Effect": "Allow" | "Deny", // Required: Allow or Deny
"Action": "action:pattern", // Required: Action to match (supports wildcards)
"Resource": "lrn:pattern", // Required: Resource LRN (supports wildcards)
"Condition": { // Optional: Conditions that must be met
"ConditionType": {
"field:name": ["value1", "value2"]
}
}
}"NotAction": Match all actions EXCEPT these"NotResource": Match all resources EXCEPT these
// Simple allow
{
"Effect": "Allow",
"Action": "queue:read",
"Resource": "lrn:leo:rstreams:::queue/public-*"
}
// Deny everything except specific action
{
"Effect": "Deny",
"NotAction": "queue:read",
"Resource": "*"
}
// Allow everything except sensitive resources
{
"Effect": "Allow",
"Action": "*",
"NotResource": "lrn:leo:rstreams:::queue/sensitive-*"
}Conditions allow you to add fine-grained control based on request attributes.
StringEquals: Exact string matchStringNotEquals: String does not matchStringLike: Pattern match with wildcards (*for any characters)StringNotLike: Does not match pattern
{
"Condition": {
"StringEquals": {
"context:department": "engineering"
}
}
}
{
"Condition": {
"StringLike": {
"context:email": "*@company.com"
}
}
}Null: Check if field exists (true) or has a value (false)
{
"Condition": {
"Null": {
"context:account": false // Account must exist and have a value
}
}
}IpAddress: Check if IP is within CIDR range(s)
{
"Condition": {
"IpAddress": {
"aws:sourceip": ["192.168.1.0/24", "10.0.0.0/8"]
}
}
}All condition types can be prefixed with ForAllValues: or ForAnyValue: to work with arrays:
ForAllValues:: All values in the array must matchForAnyValue:: At least one value in the array must match
{
"Condition": {
"ForAllValues:StringLike": {
"context:roles": "team/*" // All roles must start with "team/"
}
}
}
{
"Condition": {
"ForAnyValue:StringEquals": {
"context:permissions": "admin" // At least one permission is "admin"
}
}
}The system flattens the request object with : separators. For example:
// Request object:
{
action: "data:read",
lrn: "lrn:leo:data:::records",
context: {
account: "123",
nested: {
value: "test"
}
},
aws: {
sourceIp: "192.168.1.1"
}
}
// Becomes flattened as:
{
"action": "data:read",
"lrn": "lrn:leo:data:::records",
"context:account": "123",
"context:nested:value": "test",
"aws:sourceip": "192.168.1.1"
}Context variables allow policies to be dynamic based on user attributes.
Context is stored with the user and can be referenced in policies using ${variable.name} syntax:
// User object
{
identity_id: "user-123",
identities: ["role/user"],
context: {
account: "999",
department: "sales",
regions: ["us-east-1", "us-west-2"]
}
}
// Policy using context variables
{
"Effect": "Allow",
"Action": "data:read",
"Resource": "lrn:leo:data:::account/${context.account}/*"
}
// At runtime, this becomes:
{
"Effect": "Allow",
"Action": "data:read",
"Resource": "lrn:leo:data:::account/999/*"
}- String values:
${context.account}→"999" - Array values:
${context.regions}→"us-east-1,us-west-2" - Function values: Can define functions in context that are called during substitution
// Policy
{
"Condition": {
"StringEquals": {
"request:account": "${context.account}"
}
}
}
// If context.account is an array ["123", "456"], becomes:
{
"Condition": {
"StringEquals": {
"request:account": ["123", "456"]
}
}
}For testing or applications where you want to define policies in code instead of DynamoDB, use bootstrap mode:
const leoAuth = require('leo-auth');
leoAuth.bootstrap({
actions: 'myapp', // Action prefix
resource: 'lrn:leo:myapp:', // Resource prefix (auto-completed to 6 parts)
// Define which identities get which policies
identities: {
'*': ['PublicAccess'],
'role/admin': ['PublicAccess', 'AdminAccess'],
'role/user': ['PublicAccess', 'UserAccess']
},
// Define the actual policies
policies: {
PublicAccess: [{
Effect: 'Allow',
Action: 'health', // Will become 'myapp:health'
Resource: 'system/health' // Will become 'lrn:leo:myapp:::system/health'
}],
AdminAccess: [{
Effect: 'Allow',
Action: '*',
Resource: '*'
}],
UserAccess: [{
Effect: 'Allow',
Action: 'read',
Resource: 'data/public/*'
}, {
Effect: 'Deny',
Action: 'delete',
Resource: '*'
}]
}
});
// Now when you call authorize, it will use these in-memory policies
// instead of querying DynamoDB-
Actions without
:prefix: Automatically prefixed with theactionsvalue"read"→"myapp:read""something:write"→"something:write"(already has colon, not prefixed)
-
Resources without
lrnprefix: Automatically prefixed with theresourcevalue"data/records"→"lrn:leo:myapp:::data/records""lrn:other:thing"→"lrn:other:thing"(already starts with lrn, not prefixed)
Retrieves a user object from the authentication system.
Parameters:
event(Object|String): Lambda event object withrequestContext.identity.cognitoIdentityId, or a cognito ID string
Returns: Promise<User> - User object with authorize() method
Example:
const user = await leoAuth.getUser(event);
console.log(user.identity_id);
console.log(user.identities);
console.log(user.context);Authorizes a user for a specific action on a resource.
Parameters:
event(Object): Lambda event object with request contextresource(Object): Resource definition with:lrn(String): Leo Resource Nameaction(String): Action to performcontext(Array|String): Optional context fields to include[system](Object): System-specific parameters for LRN variable substitution
user(Object): Optional pre-fetched user object
Returns: Promise<User> - Authorized user object
Throws: "Access Denied" if authorization fails
Example:
await leoAuth.authorize(event, {
lrn: 'lrn:leo:rstreams:::queue/{queueId}',
action: 'write',
rstreams: {
queueId: 'my-queue'
}
});Configure authorization policies in code rather than DynamoDB.
Parameters:
config(Object):actions(String): Action prefix for unprefixed actionsresource(String): Resource LRN prefix for unprefixed resourcesidentities(Object): Map of identity names to policy arrayspolicies(Object): Map of policy names to policy statement arrays
Returns: void
Example:
leoAuth.bootstrap({
actions: 'api',
resource: 'lrn:leo:api:',
identities: {
'*': ['PublicPolicy'],
'role/admin': ['PublicPolicy', 'AdminPolicy']
},
policies: {
PublicPolicy: [{
Effect: 'Allow',
Action: 'health',
Resource: 'health'
}],
AdminPolicy: [{
Effect: 'Allow',
Action: '*',
Resource: '*'
}]
}
});Access to the resolved configuration.
Example:
const leoAuth = require('leo-auth');
console.log(leoAuth.configuration.LeoAuth); // Policy table name
console.log(leoAuth.configuration.LeoAuthUser); // User table nameHere's a simple test pattern:
const leoAuth = require('leo-auth');
// Use bootstrap for testing
leoAuth.bootstrap({
actions: 'test',
resource: 'lrn:leo:test:',
identities: {
'role/test': ['TestPolicy']
},
policies: {
TestPolicy: [{
Effect: 'Allow',
Action: 'read',
Resource: '*'
}]
}
});
async function testAuthorization() {
const mockEvent = {
requestContext: {
requestId: 'test-123',
identity: {
cognitoIdentityId: 'test-user'
}
}
};
// Create a mock user
const user = {
identity_id: 'test-user',
identities: ['role/test'],
context: {}
};
try {
await leoAuth.authorize(mockEvent, {
lrn: 'lrn:leo:test:::resource',
action: 'read',
test: {}
}, user);
console.log('✓ Authorization passed');
} catch (error) {
console.log('✗ Authorization failed:', error);
}
}
testAuthorization();-
Use Deny Sparingly: Deny always overrides Allow, so use it only for explicit restrictions
-
Principle of Least Privilege: Start with minimal permissions and add more as needed
-
Use Wildcards Carefully:
*in resources can grant broad access; prefer specific patterns -
Leverage Context Variables: Store account IDs, regions, etc. in user context for dynamic policies
-
Test Conditions Thoroughly: Conditions can be complex; test edge cases
-
Use Bootstrap for Development: Define policies in code during development, DynamoDB for production
-
Document Your Identities: Maintain clear documentation of what each role/identity can do
-
Version Your Policies: When updating policies in DynamoDB, consider keeping old versions for rollback
- Check if user's identities are correct
- Verify policies exist for those identities (and
*) - Review Deny policies first (they override Allow)
- Check if conditions are failing
- Verify LRN and action patterns match
- Flatten your request object mentally and check field names use
:separator - Remember all fields are lowercase in conditions
- Verify the condition type supports your data type
- Ensure context is defined on the user object
- Check variable syntax:
${context.field}not$context.field - If variable doesn't exist, an error is thrown
- Call
bootstrap()before any authorization calls - Bootstrap overrides DynamoDB; you can't mix both in one request
MIT
Issues and pull requests welcome at LeoPlatform/auth-sdk