feat(machine-identity): Add AWS attributes for ABAC

This commit is contained in:
x032205
2025-07-23 16:30:55 -04:00
parent 14d4cfdbe4
commit e798eb2a4e
7 changed files with 144 additions and 65 deletions
+9
View File
@@ -125,6 +125,15 @@ declare module "@fastify/request-context" {
namespace: string; namespace: string;
name: string; name: string;
}; };
aws?: {
accountId: string;
arn: string;
userId: string;
partition: string;
service: string;
resourceType: string;
resourceName: string;
};
}; };
identityPermissionMetadata?: Record<string, unknown>; // filled by permission service identityPermissionMetadata?: Record<string, unknown>; // filled by permission service
assumedPrivilegeDetails?: { requesterId: string; actorId: string; actorType: ActorType; projectId: string }; assumedPrivilegeDetails?: { requesterId: string; actorId: string; actorType: ActorType; projectId: string };
@@ -162,6 +162,12 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => {
kubernetes: token?.identityAuth?.kubernetes kubernetes: token?.identityAuth?.kubernetes
}); });
} }
if (token?.identityAuth?.aws) {
requestContext.set("identityAuthInfo", {
identityId: identity.identityId,
aws: token?.identityAuth?.aws
});
}
break; break;
} }
case AuthMode.SERVICE_TOKEN: { case AuthMode.SERVICE_TOKEN: {
@@ -15,5 +15,16 @@ export type TIdentityAccessTokenJwtPayload = {
namespace: string; namespace: string;
name: string; name: string;
}; };
aws?: {
accountId: string;
arn: string;
userId: string;
// Derived from ARN
partition: string; // "aws", "aws-gov", "aws-cn"
service: string; // "iam"
resourceType: string; // "user" or "role"
resourceName: string;
};
}; };
}; };
@@ -1,67 +1,91 @@
interface PrincipalArnEntity {
Partition: string;
Service: "iam" | "sts";
AccountNumber: string;
Type: "user" | "role" | "instance-profile";
Path: string;
FriendlyName: string;
SessionInfo: string; // Only populated for assumed-role
}
export const extractPrincipalArnEntity = (arn: string): PrincipalArnEntity => {
// split the ARN into parts using ":" as the delimiter
const fullParts = arn.split(":");
if (fullParts.length !== 6) {
throw new Error(`Unrecognized ARN: "${arn}" contains ${fullParts.length} colon-separated parts, expected 6`);
}
const [prefix, partition, service, , accountNumber, resource] = fullParts;
if (prefix !== "arn") {
throw new Error(`Unrecognized ARN: "${arn}" does not begin with "arn:"`);
}
// validate the service is either 'iam' or 'sts'
if (service !== "iam" && service !== "sts") {
throw new Error(`Unrecognized service: "${service}" in ARN "${arn}", expected "iam" or "sts"`);
}
// parse the last part of the ARN which describes the resource
const parts = resource.split("/");
if (parts.length < 2) {
throw new Error(
`Unrecognized ARN: "${resource}" in ARN "${arn}" contains fewer than 2 slash-separated parts (expected type/name)`
);
}
const [rawType, ...rest] = parts;
let finalType: PrincipalArnEntity["Type"];
let friendlyName: string = parts[parts.length - 1];
let path: string = "";
let sessionInfo: string = "";
// handle different types of resources
switch (rawType) {
case "assumed-role": {
if (rest.length < 2) {
throw new Error(
`Unrecognized ARN: "${resource}" for assumed-role in ARN "${arn}" contains fewer than 3 slash-separated parts (type/roleName/sessionId)`
);
}
// assumed roles use a special format where the friendly name is the role name
const [roleName, sessionId] = rest;
finalType = "role"; // treat assumed role case as role
friendlyName = roleName;
sessionInfo = sessionId;
break;
}
case "user":
case "role":
case "instance-profile":
finalType = rawType;
path = rest.slice(0, -1).join("/");
break;
default:
throw new Error(
`Unrecognized principal type: "${rawType}" in ARN "${arn}". Expected "user", "role", "instance-profile", or "assumed-role".`
);
}
const entity: PrincipalArnEntity = {
Partition: partition,
Service: service,
AccountNumber: accountNumber,
Type: finalType,
Path: path,
FriendlyName: friendlyName,
SessionInfo: sessionInfo
};
return entity;
};
/** /**
* Extracts the identity ARN from the GetCallerIdentity response to one of the following formats: * Extracts the identity ARN from the GetCallerIdentity response to one of the following formats:
* - arn:aws:iam::123456789012:user/MyUserName * - arn:aws:iam::123456789012:user/MyUserName
* - arn:aws:iam::123456789012:role/MyRoleName * - arn:aws:iam::123456789012:role/MyRoleName
*/ */
export const extractPrincipalArn = (arn: string) => { export const extractPrincipalArn = (arn: string) => {
// split the ARN into parts using ":" as the delimiter const entity = extractPrincipalArnEntity(arn);
const fullParts = arn.split(":");
if (fullParts.length !== 6) {
throw new Error(`Unrecognized ARN: contains ${fullParts.length} colon-separated parts, expected 6`);
}
const [prefix, partition, service, , accountNumber, resource] = fullParts;
if (prefix !== "arn") {
throw new Error('Unrecognized ARN: does not begin with "arn:"');
}
// structure to hold the parsed data
const entity = {
Partition: partition,
Service: service,
AccountNumber: accountNumber,
Type: "",
Path: "",
FriendlyName: "",
SessionInfo: ""
};
// validate the service is either 'iam' or 'sts'
if (entity.Service !== "iam" && entity.Service !== "sts") {
throw new Error(`Unrecognized service: ${entity.Service}, not one of iam or sts`);
}
// parse the last part of the ARN which describes the resource
const parts = resource.split("/");
if (parts.length < 2) {
throw new Error(`Unrecognized ARN: "${resource}" contains fewer than 2 slash-separated parts`);
}
const [type, ...rest] = parts;
entity.Type = type;
entity.FriendlyName = parts[parts.length - 1];
// handle different types of resources
switch (entity.Type) {
case "assumed-role": {
if (rest.length < 2) {
throw new Error(`Unrecognized ARN: "${resource}" contains fewer than 3 slash-separated parts`);
}
// assumed roles use a special format where the friendly name is the role name
const [roleName, sessionId] = rest;
entity.Type = "role"; // treat assumed role case as role
entity.FriendlyName = roleName;
entity.SessionInfo = sessionId;
break;
}
case "user":
case "role":
case "instance-profile":
// standard cases: just join back the path if there's any
entity.Path = rest.slice(0, -1).join("/");
break;
default:
throw new Error(`Unrecognized principal type: "${entity.Type}"`);
}
return `arn:aws:iam::${entity.AccountNumber}:${entity.Type}/${entity.FriendlyName}`; return `arn:aws:iam::${entity.AccountNumber}:${entity.Type}/${entity.FriendlyName}`;
}; };
@@ -22,7 +22,7 @@ import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identit
import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types";
import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns";
import { TIdentityAwsAuthDALFactory } from "./identity-aws-auth-dal"; import { TIdentityAwsAuthDALFactory } from "./identity-aws-auth-dal";
import { extractPrincipalArn } from "./identity-aws-auth-fns"; import { extractPrincipalArn, extractPrincipalArnEntity } from "./identity-aws-auth-fns";
import { import {
TAttachAwsAuthDTO, TAttachAwsAuthDTO,
TAwsGetCallerIdentityHeaders, TAwsGetCallerIdentityHeaders,
@@ -107,7 +107,7 @@ export const identityAwsAuthServiceFactory = ({
const { const {
data: { data: {
GetCallerIdentityResponse: { GetCallerIdentityResponse: {
GetCallerIdentityResult: { Account, Arn } GetCallerIdentityResult: { Account, Arn, UserId }
} }
} }
}: { data: TGetCallerIdentityResponse } = await axios({ }: { data: TGetCallerIdentityResponse } = await axios({
@@ -168,11 +168,25 @@ export const identityAwsAuthServiceFactory = ({
}); });
const appCfg = getConfig(); const appCfg = getConfig();
const splitArn = extractPrincipalArnEntity(Arn);
const accessToken = crypto.jwt().sign( const accessToken = crypto.jwt().sign(
{ {
identityId: identityAwsAuth.identityId, identityId: identityAwsAuth.identityId,
identityAccessTokenId: identityAccessToken.id, identityAccessTokenId: identityAccessToken.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN,
identityAuth: {
aws: {
accountId: Account,
arn: Arn,
userId: UserId,
// Derived from ARN
partition: splitArn.Partition,
service: splitArn.Service,
resourceType: splitArn.Type,
resourceName: splitArn.FriendlyName
}
}
} as TIdentityAccessTokenJwtPayload, } as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET, appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
@@ -74,6 +74,21 @@ For methods like OIDC, these come as claims in the token and can be made availab
<img src="/images/platform/access-controls/abac-policy-k8s-format.png" /> <img src="/images/platform/access-controls/abac-policy-k8s-format.png" />
</Tab> </Tab>
<Tab title="AWS Attributes">
For identities authenticated using AWS Auth, several attributes can be accessed:
```
{{ identity.auth.aws.accountId }}
{{ identity.auth.aws.arn }}
{{ identity.auth.aws.userId }}
{{ identity.auth.aws.partition }}
{{ identity.auth.aws.service }}
{{ identity.auth.aws.resourceType }}
{{ identity.auth.aws.resourceName }}
```
<img src="/images/platform/access-controls/abac-policy-aws-format.png" />
</Tab>
<Tab title="Other Authentication Method Attributes"> <Tab title="Other Authentication Method Attributes">
At the moment we only support OIDC claims. Payloads on other authentication methods are not yet accessible. At the moment we only support OIDC claims. Payloads on other authentication methods are not yet accessible.
</Tab> </Tab>
Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB