From 86509661aa255c0f736b2494b2277d1446857de4 Mon Sep 17 00:00:00 2001 From: philip-ellis-sp Date: Fri, 30 Oct 2026 12:01:22 -0400 Subject: [PATCH] added script to generate scopes file Co-Authored-By: Claude Opus 5.5 --- sdk-resources/build-versioned-sdk.js | 20 +++ sdk-resources/emit-scope-manifest.js | 183 +++++++++++++++++++++++++++ setup.py | 2 +- 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 sdk-resources/emit-scope-manifest.js diff --git a/sdk-resources/build-versioned-sdk.js b/sdk-resources/build-versioned-sdk.js index fae27b1cd..bef33b590 100644 --- a/sdk-resources/build-versioned-sdk.js +++ b/sdk-resources/build-versioned-sdk.js @@ -36,6 +36,7 @@ const fs = require("fs"); const path = require("path"); const { spawnSync } = require("child_process"); +const scopeManifest = require("./emit-scope-manifest"); // --------------------------------------------------------------------------- // Constants @@ -1085,6 +1086,9 @@ function main() { console.log(` cleaned ${staleDirs.length} director${staleDirs.length === 1 ? "y" : "ies"}, ${looseFiles.length} loose file(s)\n`); } + // operationId → scope data, written to sailpoint-scopes.json after the loop + const scopeOperations = {}; + const results = { total: partitions.length, success: [], @@ -1152,10 +1156,26 @@ function main() { continue; } + // --- Scope data for `sail scopes analyze` --- + try { + Object.assign(scopeOperations, scopeManifest.collectOperations(bundle.outputSpec, partition, scopeManifest.pythonName)); + } catch (err) { + console.warn(` ! could not read scopes: ${err.message}`); + } + results.success.push(partition); console.log(` ✓ ${partition} → sailpoint/${gen.packageDir}/`); } + // Scope manifest for `sail scopes analyze` + const scopes = scopeManifest.writeScopeManifest(SAILPOINT_DIR, { + sdk: "python", + sdkVersion: (fs.readFileSync(path.join(SDK_ROOT, "setup.py"), "utf8").match(/^VERSION = "([^"]+)"/m) || [])[1], + operations: scopeOperations, + onlyPartition, + }); + console.log(`\n[SCOPES] Wrote ${scopes.count} operation(s) to ${path.relative(SDK_ROOT, scopes.outPath)}`); + // Cleanup if (!keepTmp) { console.log("\n[CLEANUP] Removing .sdk-build-tmp/ ..."); diff --git a/sdk-resources/emit-scope-manifest.js b/sdk-resources/emit-scope-manifest.js new file mode 100644 index 000000000..1b1cfaf83 --- /dev/null +++ b/sdk-resources/emit-scope-manifest.js @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/** + * emit-scope-manifest.js + * + * Writes sailpoint-scopes.json, which ships inside the SDK package. The + * SailPoint CLI (`sail scopes analyze`) reads it to map the SDK calls in a + * project to the OAuth scopes that they need. + * + * This file is identical in every SDK repo. Each build-versioned-sdk.js + * picks the naming function for its language. + * + * Format (schemaVersion 1): + * { + * "schemaVersion": 1, "sdk": "typescript", "sdkVersion": "2.2.38", + * "operations": { + * "": { + * "resource": "", "tag": "", "sdkName": "", + * "httpMethod": "GET", "path": "/accounts/v1", + * "userAuth": [...] | null, "applicationAuth": [...] | null, + * "defaultScope": true?, "public": true?, "userLevels": [...]? + * } + * } + * } + * + * userAuth and applicationAuth list scopes of which any one is enough. null + * means that the auth type cannot call the operation, and [] means that no + * scope is needed. defaultScope marks operations without a security block, + * which inherit the spec default (sp:scopes:all). + */ + +const fs = require("fs"); +const path = require("path"); + +const SCHEMA_VERSION = 1; +const MANIFEST_NAME = "sailpoint-scopes.json"; +const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"]; + +/** + * Read every operation of a bundled partition spec (JSON). + * + * @param {string} bundledSpecPath redocly bundle output for the partition + * @param {string} partition partition (resource) name + * @param {Function} nameFor (operationId, { httpMethod, path }) => SDK name, or null to skip + * @returns {Object} operations keyed by operationId + */ +function collectOperations(bundledSpecPath, partition, nameFor) { + const spec = JSON.parse(fs.readFileSync(bundledSpecPath, "utf8")); + const globalSecurity = spec.security || []; + const operations = {}; + + for (const [opPath, item] of Object.entries(spec.paths || {})) { + for (const method of HTTP_METHODS) { + const op = item[method]; + if (!op || !op.operationId) continue; + + const httpMethod = method.toUpperCase(); + const sdkName = nameFor(op.operationId, { httpMethod, path: opPath }); + if (!sdkName) continue; + + const entry = { + resource: partition, + tag: (op.tags || [])[0], + sdkName, + httpMethod, + path: opPath, + ...securityOf(op, globalSecurity), + }; + const levels = op["x-sailpoint-userLevels"] || op["x-sailpont-userLevels"]; + if (levels) entry.userLevels = levels; + operations[op.operationId] = entry; + } + } + return operations; +} + +function securityOf(op, globalSecurity) { + const hasOwn = Object.prototype.hasOwnProperty.call(op, "security"); + const security = hasOwn ? op.security : globalSecurity; + const result = { userAuth: null, applicationAuth: null }; + if (!hasOwn) result.defaultScope = true; + + if (security.length === 0 || security.some((req) => Object.keys(req).length === 0)) { + return { ...result, userAuth: [], applicationAuth: [], public: true }; + } + for (const req of security) { + for (const [scheme, scopes] of Object.entries(req)) { + const key = scheme === "applicationAuth" ? "applicationAuth" + : (scheme === "userAuth" || scheme === "UserContextAuth") ? "userAuth" + : null; + if (!key) continue; + result[key] = [...new Set([...(result[key] || []), ...scopes])]; + } + } + return result; +} + +/** + * Write the manifest. For a single-partition build, an existing manifest is + * updated: only the partitions that were rebuilt are replaced. + */ +function writeScopeManifest(outDir, { sdk, sdkVersion, operations, onlyPartition }) { + const outPath = path.join(outDir, MANIFEST_NAME); + let merged = operations; + + if (onlyPartition && fs.existsSync(outPath)) { + const rebuilt = new Set(Object.values(operations).map((op) => op.resource)); + const existing = JSON.parse(fs.readFileSync(outPath, "utf8")).operations || {}; + merged = {}; + for (const [id, op] of Object.entries(existing)) { + if (!rebuilt.has(op.resource)) merged[id] = op; + } + Object.assign(merged, operations); + } + + const sorted = {}; + for (const id of Object.keys(merged).sort()) sorted[id] = merged[id]; + + const manifest = { schemaVersion: SCHEMA_VERSION, sdk }; + if (sdkVersion) manifest.sdkVersion = sdkVersion; + manifest.operations = sorted; + + fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2) + "\n", "utf8"); + return { outPath, count: Object.keys(sorted).length }; +} + +// --------------------------------------------------------------------------- +// Naming functions. They match the openapi-generator method names. +// --------------------------------------------------------------------------- + +/** TypeScript and Angular: the operationId as is. */ +const typescriptName = (id) => id; + +/** Go: the operationId with its first letter in upper case. */ +const goName = (id) => id.charAt(0).toUpperCase() + id.slice(1); + +/** Python: openapi-generator underscore(). */ +function pythonName(id) { + return id + .replace(/([A-Z]+)([A-Z][a-z][a-z]+)/g, "$1_$2") + .replace(/([a-z\d])([A-Z])/g, "$1_$2") + .replace(/-/g, "_") + .toLowerCase(); +} + +/** + * PowerShell: read the generated Api/*.ps1 files under dir and map + * "METHOD /path" to the cmdlet name. Cmdlet verbs do not follow a simple + * rule, so they are not derived from the operationId. + */ +function readPowerShellCmdlets(dir) { + const cmdlets = {}; + const fnPattern = /^function ([A-Za-z]+-[A-Za-z0-9]+)\s*\{/gm; + + const visit = (d) => { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const full = path.join(d, entry.name); + if (entry.isDirectory()) { + visit(full); + } else if (entry.name.endsWith(".ps1") && path.basename(d) === "Api") { + const text = fs.readFileSync(full, "utf8"); + const starts = [...text.matchAll(fnPattern)]; + starts.forEach((m, i) => { + const body = text.slice(m.index, i + 1 < starts.length ? starts[i + 1].index : text.length); + const uri = body.match(/\$LocalVarUri = '([^']+)'/); + const method = body.match(/Invoke-ApiClient -Method '([A-Z]+)'/); + if (uri && method) cmdlets[`${method[1]} ${uri[1]}`] = m[1]; + }); + } + } + }; + if (fs.existsSync(dir)) visit(dir); + return cmdlets; +} + +module.exports = { + MANIFEST_NAME, + collectOperations, + writeScopeManifest, + typescriptName, + goName, + pythonName, + readPowerShellCmdlets, +}; diff --git a/setup.py b/setup.py index b255740aa..f9fcfad9b 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,7 @@ license="MIT", long_description_content_type='text/markdown', long_description=long_description, - package_data={"sailpoint": ["py.typed"]}, + package_data={"sailpoint": ["py.typed", "sailpoint-scopes.json"]}, classifiers=[ "Development Status :: 4 - Beta",