@datacules/agent-identity-store-azure 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +36 -0
- package/src/AzureKeyVaultCredentialStore.ts +184 -0
- package/src/index.ts +2 -0
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@datacules/agent-identity-store-azure",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Azure Key Vault + Table Storage credential store for @datacules/agent-identity",
|
|
6
|
+
"main": "./dist/cjs/index.js",
|
|
7
|
+
"module": "./dist/esm/index.js",
|
|
8
|
+
"types": "./dist/types/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/esm/index.js",
|
|
12
|
+
"require": "./dist/cjs/index.js",
|
|
13
|
+
"types": "./dist/types/index.d.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.build.json && tsc -p tsconfig.cjs.json",
|
|
18
|
+
"type-check": "tsc --noEmit"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@azure/keyvault-secrets": "^4.8.0",
|
|
22
|
+
"@azure/data-tables": "^13.2.2",
|
|
23
|
+
"@azure/identity": "^4.3.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@datacules/agent-identity": "^0.1.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@datacules/agent-identity": "*",
|
|
30
|
+
"typescript": "^5"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"src"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure Key Vault + Azure Table Storage CredentialStore implementation.
|
|
3
|
+
*
|
|
4
|
+
* Key Vault holds each credential as a secret whose name is the credential ref.
|
|
5
|
+
* The secret value is the JSON-serialised Credential object.
|
|
6
|
+
* The secret's "content-type" tag carries the status (active | pending | revoked)
|
|
7
|
+
* so listActive() can skip inactive secrets without fetching their values.
|
|
8
|
+
*
|
|
9
|
+
* Table Storage holds migration reservation locks.
|
|
10
|
+
* Table name: agent-identity-locks
|
|
11
|
+
* Partition key: "lock" (constant — all locks in one partition for simplicity)
|
|
12
|
+
* Row key: the credential ref being locked
|
|
13
|
+
* Columns: migrationId (string), expiresAt (number — Unix epoch seconds)
|
|
14
|
+
*
|
|
15
|
+
* Azure setup:
|
|
16
|
+
* 1. Create an Azure Key Vault and store each Credential as a JSON secret.
|
|
17
|
+
* Set the secret's ContentType to "active", "pending", or "revoked".
|
|
18
|
+
* 2. Create a Storage Account and a Table named "agentidentitylocks".
|
|
19
|
+
* (Table names may not contain hyphens — use "agentidentitylocks".)
|
|
20
|
+
* 3. Grant the running identity:
|
|
21
|
+
* Key Vault Secrets User (read secrets)
|
|
22
|
+
* Key Vault Secrets Officer (only needed for write operations)
|
|
23
|
+
* Storage Table Data Contributor (read + write locks table)
|
|
24
|
+
* 4. Set AZURE_KEYVAULT_URL and AZURE_TABLES_ENDPOINT environment variables,
|
|
25
|
+
* or pass them as constructor options.
|
|
26
|
+
* DefaultAzureCredential resolves auth automatically from:
|
|
27
|
+
* - Managed Identity (production)
|
|
28
|
+
* - AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET env vars
|
|
29
|
+
* - Azure CLI (local development)
|
|
30
|
+
* - Workload Identity (AKS)
|
|
31
|
+
*/
|
|
32
|
+
import { SecretClient } from '@azure/keyvault-secrets';
|
|
33
|
+
import { TableClient, odata } from '@azure/data-tables';
|
|
34
|
+
import { DefaultAzureCredential } from '@azure/identity';
|
|
35
|
+
import type { Credential, CredentialKind, CredentialStore } from '@datacules/agent-identity';
|
|
36
|
+
|
|
37
|
+
export interface AzureKeyVaultCredentialStoreOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Full URL of the Azure Key Vault, e.g. https://my-vault.vault.azure.net
|
|
40
|
+
* Falls back to AZURE_KEYVAULT_URL environment variable.
|
|
41
|
+
*/
|
|
42
|
+
keyVaultUrl?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Full URL of the Azure Table Storage endpoint,
|
|
45
|
+
* e.g. https://myaccount.table.core.windows.net
|
|
46
|
+
* Falls back to AZURE_TABLES_ENDPOINT environment variable.
|
|
47
|
+
*/
|
|
48
|
+
tablesEndpoint?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Name of the Table Storage table used for migration locks.
|
|
51
|
+
* Default: 'agentidentitylocks'
|
|
52
|
+
* Note: Azure Table names may not contain hyphens.
|
|
53
|
+
*/
|
|
54
|
+
locksTable?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface LockEntity {
|
|
58
|
+
partitionKey: string;
|
|
59
|
+
rowKey: string;
|
|
60
|
+
migrationId: string;
|
|
61
|
+
expiresAt: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class AzureKeyVaultCredentialStore implements CredentialStore {
|
|
65
|
+
private readonly secrets: SecretClient;
|
|
66
|
+
private readonly table: TableClient;
|
|
67
|
+
|
|
68
|
+
constructor(options: AzureKeyVaultCredentialStoreOptions = {}) {
|
|
69
|
+
const vaultUrl =
|
|
70
|
+
options.keyVaultUrl ?? process.env['AZURE_KEYVAULT_URL'] ?? '';
|
|
71
|
+
if (!vaultUrl) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
'AzureKeyVaultCredentialStore: keyVaultUrl is required. ' +
|
|
74
|
+
'Pass it as an option or set AZURE_KEYVAULT_URL.'
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const tablesEndpoint =
|
|
79
|
+
options.tablesEndpoint ?? process.env['AZURE_TABLES_ENDPOINT'] ?? '';
|
|
80
|
+
if (!tablesEndpoint) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
'AzureKeyVaultCredentialStore: tablesEndpoint is required. ' +
|
|
83
|
+
'Pass it as an option or set AZURE_TABLES_ENDPOINT.'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const locksTable = options.locksTable ?? 'agentidentitylocks';
|
|
88
|
+
const credential = new DefaultAzureCredential();
|
|
89
|
+
|
|
90
|
+
this.secrets = new SecretClient(vaultUrl, credential);
|
|
91
|
+
this.table = new TableClient(tablesEndpoint, locksTable, credential);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── CredentialStore: reads ──────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
async findByRef(ref: string): Promise<Credential | null> {
|
|
97
|
+
try {
|
|
98
|
+
const secret = await this.secrets.getSecret(ref);
|
|
99
|
+
if (!secret.value) return null;
|
|
100
|
+
// contentType carries status; skip non-active secrets without parsing JSON
|
|
101
|
+
if (secret.properties.contentType !== 'active') return null;
|
|
102
|
+
const cred: Credential = JSON.parse(secret.value);
|
|
103
|
+
return cred.status === 'active' ? cred : null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async listActive(): Promise<Credential[]> {
|
|
110
|
+
const results: Credential[] = [];
|
|
111
|
+
try {
|
|
112
|
+
for await (const secretProperties of this.secrets.listPropertiesOfSecrets()) {
|
|
113
|
+
// Only fetch value for secrets tagged as active
|
|
114
|
+
if (secretProperties.contentType !== 'active') continue;
|
|
115
|
+
if (!secretProperties.enabled) continue;
|
|
116
|
+
const name = secretProperties.name;
|
|
117
|
+
if (!name) continue;
|
|
118
|
+
try {
|
|
119
|
+
const secret = await this.secrets.getSecret(name);
|
|
120
|
+
if (!secret.value) continue;
|
|
121
|
+
const cred: Credential = JSON.parse(secret.value);
|
|
122
|
+
if (cred.status === 'active') results.push(cred);
|
|
123
|
+
} catch {
|
|
124
|
+
// malformed secret — skip
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// Key Vault unreachable — return empty rather than throw
|
|
129
|
+
}
|
|
130
|
+
return results;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async listByKind(kind: CredentialKind): Promise<Credential[]> {
|
|
134
|
+
const all = await this.listActive();
|
|
135
|
+
return all.filter((c) => c.kind === kind);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── CredentialStore: migration locks ────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
async reserve(ref: string, migrationId: string, ttlSeconds: number): Promise<boolean> {
|
|
141
|
+
const expiresAt = Math.floor(Date.now() / 1000) + ttlSeconds;
|
|
142
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
143
|
+
|
|
144
|
+
// Check for an existing unexpired lock held by a different migration
|
|
145
|
+
try {
|
|
146
|
+
const existing = await this.table.getEntity<LockEntity>('lock', ref);
|
|
147
|
+
if (
|
|
148
|
+
existing.migrationId !== migrationId &&
|
|
149
|
+
existing.expiresAt > nowSeconds
|
|
150
|
+
) {
|
|
151
|
+
return false; // locked by another migration and not yet expired
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
// Entity does not exist — proceed to create
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Upsert the lock (merge semantics — overwrites existing row if present)
|
|
158
|
+
try {
|
|
159
|
+
await this.table.upsertEntity<LockEntity>(
|
|
160
|
+
{
|
|
161
|
+
partitionKey: 'lock',
|
|
162
|
+
rowKey: ref,
|
|
163
|
+
migrationId,
|
|
164
|
+
expiresAt,
|
|
165
|
+
},
|
|
166
|
+
'Replace'
|
|
167
|
+
);
|
|
168
|
+
return true;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async release(ref: string, migrationId: string): Promise<void> {
|
|
175
|
+
try {
|
|
176
|
+
const existing = await this.table.getEntity<LockEntity>('lock', ref);
|
|
177
|
+
// Only delete if this migration owns the lock
|
|
178
|
+
if (existing.migrationId !== migrationId) return;
|
|
179
|
+
await this.table.deleteEntity('lock', ref);
|
|
180
|
+
} catch {
|
|
181
|
+
// Already released or never held — idempotent
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/index.ts
ADDED