@oandriie/backstage-plugin-keycloak-backend
Version:
A Backend backend plugin for Keycloak
562 lines (548 loc) • 17.9 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
var catalogModel = require('@backstage/catalog-model');
var errors = require('@backstage/errors');
var inclusion = require('inclusion');
var lodash = require('lodash');
var uuid = require('uuid');
var backendPluginApi = require('@backstage/backend-plugin-api');
var alpha = require('@backstage/plugin-catalog-node/alpha');
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
function _interopNamespaceCompat(e) {
if (e && typeof e === 'object' && 'default' in e) return e;
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var inclusion__default = /*#__PURE__*/_interopDefaultCompat(inclusion);
var uuid__namespace = /*#__PURE__*/_interopNamespaceCompat(uuid);
const KEYCLOAK_ID_ANNOTATION = "keycloak.org/id";
const KEYCLOAK_REALM_ANNOTATION = "keycloak.org/realm";
const KEYCLOAK_ENTITY_QUERY_SIZE = 100;
const readProviderConfig = (id, providerConfigInstance) => {
const baseUrl = providerConfigInstance.getString("baseUrl");
const realm = providerConfigInstance.getOptionalString("realm") ?? "master";
const loginRealm = providerConfigInstance.getOptionalString("loginRealm") ?? "master";
const username = providerConfigInstance.getOptionalString("username");
const password = providerConfigInstance.getOptionalString("password");
const clientId = providerConfigInstance.getOptionalString("clientId");
const clientSecret = providerConfigInstance.getOptionalString("clientSecret");
const userQuerySize = providerConfigInstance.getOptionalNumber("userQuerySize");
const groupQuerySize = providerConfigInstance.getOptionalNumber("groupQuerySize");
if (clientId && !clientSecret) {
throw new errors.InputError(
`clientSecret must be provided when clientId is defined.`
);
}
if (clientSecret && !clientId) {
throw new errors.InputError(
`clientId must be provided when clientSecret is defined.`
);
}
if (username && !password) {
throw new errors.InputError(`password must be provided when username is defined.`);
}
if (password && !username) {
throw new errors.InputError(`username must be provided when password is defined.`);
}
const schedule = providerConfigInstance.has("schedule") ? backendPluginApi.readSchedulerServiceTaskScheduleDefinitionFromConfig(
providerConfigInstance.getConfig("schedule")
) : void 0;
return {
id,
baseUrl,
loginRealm,
realm,
username,
password,
clientId,
clientSecret,
schedule,
userQuerySize,
groupQuerySize
};
};
const readProviderConfigs = (config) => {
const providersConfig = config.getOptionalConfig(
"catalog.providers.keycloakOrg"
);
if (!providersConfig) {
return [];
}
return providersConfig.keys().map((id) => {
const providerConfigInstance = providersConfig.getConfig(id);
return readProviderConfig(id, providerConfigInstance);
});
};
const noopGroupTransformer = async (entity, _user, _realm) => entity;
const noopUserTransformer = async (entity, _user, _realm, _groups) => entity;
const sanitizeEmailTransformer = async (entity, _user, _realm, _groups) => {
entity.metadata.name = entity.metadata.name.replace(/[^a-zA-Z0-9]/g, "-");
return entity;
};
const parseGroup = async (keycloakGroup, realm, groupTransformer) => {
const transformer = groupTransformer ?? noopGroupTransformer;
const entity = {
apiVersion: "backstage.io/v1beta1",
kind: "Group",
metadata: {
name: keycloakGroup.name,
annotations: {
[KEYCLOAK_ID_ANNOTATION]: keycloakGroup.id,
[KEYCLOAK_REALM_ANNOTATION]: realm
}
},
spec: {
type: "group",
profile: {
displayName: keycloakGroup.name
},
// children, parent and members are updated again after all group and user transformers applied.
children: keycloakGroup.subGroups?.map((g) => g.name) ?? [],
parent: keycloakGroup.parent,
members: keycloakGroup.members
}
};
return await transformer(entity, keycloakGroup, realm);
};
const parseUser = async (user, realm, keycloakGroups, userTransformer) => {
const transformer = userTransformer ?? noopUserTransformer;
const entity = {
apiVersion: "backstage.io/v1beta1",
kind: "User",
metadata: {
name: user.username,
annotations: {
[KEYCLOAK_ID_ANNOTATION]: user.id,
[KEYCLOAK_REALM_ANNOTATION]: realm
}
},
spec: {
profile: {
email: user.email,
...user.firstName || user.lastName ? {
displayName: [user.firstName, user.lastName].filter(Boolean).join(" ")
} : {}
},
memberOf: keycloakGroups.filter((g) => g.members?.includes(user.username)).map((g) => g.entity.metadata.name)
}
};
return await transformer(entity, user, realm, keycloakGroups);
};
async function getEntities(entities, config, logger, entityQuerySize = KEYCLOAK_ENTITY_QUERY_SIZE) {
const rawEntityCount = await entities.count({ realm: config.realm });
const entityCount = typeof rawEntityCount === "number" ? rawEntityCount : rawEntityCount.count;
const pageCount = Math.ceil(entityCount / entityQuerySize);
const entityPromises = Array.from(
{ length: pageCount },
(_, i) => entities.find({
realm: config.realm,
max: entityQuerySize,
first: i * entityQuerySize
}).catch(
(err) => logger.warn("Failed to retieve Keycloak entities.", err)
)
);
const entityResults = (await Promise.all(entityPromises)).flat();
return entityResults;
}
async function getAllGroupMembers(groups, groupId, config, options) {
const querySize = options?.userQuerySize || 100;
let allMembers = [];
let page = 0;
let totalMembers = 0;
do {
const members = await groups.listMembers({
id: groupId,
max: querySize,
realm: config.realm,
first: page * querySize
});
if (members.length > 0) {
allMembers = allMembers.concat(members.map((m) => m.username));
totalMembers = members.length;
} else {
totalMembers = 0;
}
page++;
} while (totalMembers > 0);
return allMembers;
}
async function processGroupsRecursively(topLevelGroups, entities, realm) {
const allGroups = [];
for (const group of topLevelGroups) {
allGroups.push(group);
if (group.subGroupCount > 0) {
const subgroups = await entities.listSubGroups({
parentId: group.id,
first: 0,
max: group.subGroupCount,
briefRepresentation: true,
realm
});
const subGroupResults = await processGroupsRecursively(
subgroups,
entities,
realm
);
allGroups.push(...subGroupResults);
}
}
return allGroups;
}
function* traverseGroups(group) {
yield group;
for (const g of group.subGroups ?? []) {
g.parent = group.name;
yield* traverseGroups(g);
}
}
const readKeycloakRealm = async (client, config, logger, options) => {
const kUsers = await getEntities(
client.users,
config,
logger,
options?.userQuerySize
);
const topLevelKGroups = await getEntities(
client.groups,
config,
logger,
options?.groupQuerySize
);
let serverVersion;
try {
const serverInfo = await client.serverInfo.find();
serverVersion = parseInt(
serverInfo.systemInfo?.version?.slice(0, 2) || "",
10
);
} catch (error) {
throw new Error(`Failed to retrieve Keycloak server information: ${error}`);
}
const isVersion23orHigher = serverVersion >= 23;
let rawKGroups = [];
if (isVersion23orHigher) {
rawKGroups = await processGroupsRecursively(
topLevelKGroups,
client.groups,
config.realm
);
} else {
rawKGroups = topLevelKGroups.reduce(
(acc, g) => acc.concat(...traverseGroups(g)),
[]
);
}
const kGroups = await Promise.all(
rawKGroups.map(async (g) => {
g.members = await getAllGroupMembers(
client.groups,
g.id,
config,
options
);
if (isVersion23orHigher) {
if (g.subGroupCount > 0) {
g.subGroups = await client.groups.listSubGroups({
parentId: g.id,
first: 0,
max: g.subGroupCount,
briefRepresentation: false,
realm: config.realm
});
}
if (g.parentId) {
const groupParent = await client.groups.findOne({
id: g.parentId,
realm: config.realm
});
g.parent = groupParent?.name;
}
}
return g;
})
);
const parsedGroups = await kGroups.reduce(
async (promise, g) => {
const partial = await promise;
const entity = await parseGroup(
g,
config.realm,
options?.groupTransformer
);
if (entity) {
const group = {
...g,
entity
};
partial.push(group);
}
return partial;
},
Promise.resolve([])
);
const parsedUsers = await kUsers.reduce(
async (promise, u) => {
const partial = await promise;
const entity = await parseUser(
u,
config.realm,
parsedGroups,
options?.userTransformer
);
if (entity) {
const user = { ...u, entity };
partial.push(user);
}
return partial;
},
Promise.resolve([])
);
const groups = parsedGroups.map((g) => {
const entity = g.entity;
entity.spec.members = g.entity.spec.members?.flatMap((m) => {
const name = parsedUsers.find((p) => p.username === m)?.entity.metadata.name;
return name ? [name] : [];
}) ?? [];
entity.spec.children = g.entity.spec.children?.flatMap((c) => {
const child = parsedGroups.find((p) => p.name === c)?.entity.metadata.name;
return child ? [child] : [];
}) ?? [];
entity.spec.parent = parsedGroups.find(
(p) => p.name === entity.spec.parent
)?.entity.metadata.name;
return entity;
});
return { users: parsedUsers.map((u) => u.entity), groups };
};
const withLocations = (baseUrl, realm, entity) => {
const kind = entity.kind === "Group" ? "groups" : "users";
const location = `url:${baseUrl}/admin/realms/${realm}/${kind}/${entity.metadata.annotations?.[KEYCLOAK_ID_ANNOTATION]}`;
return lodash.merge(
{
metadata: {
annotations: {
[catalogModel.ANNOTATION_LOCATION]: location,
[catalogModel.ANNOTATION_ORIGIN_LOCATION]: location
}
}
},
entity
);
};
class KeycloakOrgEntityProvider {
constructor(options) {
this.options = options;
this.schedule(options.taskRunner);
}
connection;
scheduleFn;
static fromConfig(deps, options) {
const { config, logger } = deps;
return readProviderConfigs(config).map((providerConfig) => {
let taskRunner;
if ("scheduler" in options && providerConfig.schedule) {
taskRunner = options.scheduler.createScheduledTaskRunner(
providerConfig.schedule
);
} else if ("schedule" in options) {
taskRunner = options.schedule;
} else {
throw new errors.InputError(
`No schedule provided via config for KeycloakOrgEntityProvider:${providerConfig.id}.`
);
}
const provider = new KeycloakOrgEntityProvider({
id: providerConfig.id,
provider: providerConfig,
logger,
taskRunner,
userTransformer: options.userTransformer,
groupTransformer: options.groupTransformer
});
return provider;
});
}
getProviderName() {
return `KeycloakOrgEntityProvider:${this.options.id}`;
}
async connect(connection) {
this.connection = connection;
await this.scheduleFn?.();
}
/**
* Runs one complete ingestion loop. Call this method regularly at some
* appropriate cadence.
*/
async read(options) {
if (!this.connection) {
throw new errors.NotFoundError("Not initialized");
}
const logger = options?.logger ?? this.options.logger;
const provider = this.options.provider;
const { markReadComplete } = trackProgress(logger);
const KeyCloakAdminClientModule = await inclusion__default.default(
"@keycloak/keycloak-admin-client"
);
const KeyCloakAdminClient = KeyCloakAdminClientModule.default;
const kcAdminClient = new KeyCloakAdminClient({
baseUrl: provider.baseUrl,
realmName: provider.loginRealm
});
let credentials;
if (provider.username && provider.password) {
credentials = {
grantType: "password",
clientId: provider.clientId ?? "admin-cli",
username: provider.username,
password: provider.password
};
} else if (provider.clientId && provider.clientSecret) {
credentials = {
grantType: "client_credentials",
clientId: provider.clientId,
clientSecret: provider.clientSecret
};
} else {
throw new errors.InputError(
`username and password or clientId and clientSecret must be provided.`
);
}
await kcAdminClient.auth(credentials);
const { users, groups } = await readKeycloakRealm(
kcAdminClient,
provider,
logger,
{
userQuerySize: provider.userQuerySize,
groupQuerySize: provider.groupQuerySize,
userTransformer: this.options.userTransformer,
groupTransformer: this.options.groupTransformer
}
);
const { markCommitComplete } = markReadComplete({ users, groups });
await this.connection.applyMutation({
type: "full",
entities: [...users, ...groups].map((entity) => ({
locationKey: `keycloak-org-provider:${this.options.id}`,
entity: withLocations(provider.baseUrl, provider.realm, entity)
}))
});
markCommitComplete();
}
schedule(taskRunner) {
this.scheduleFn = async () => {
const id = `${this.getProviderName()}:refresh`;
await taskRunner.run({
id,
fn: async () => {
const logger = this.options.logger.child({
class: KeycloakOrgEntityProvider.prototype.constructor.name,
taskId: id,
taskInstanceId: uuid__namespace.v4()
});
try {
await this.read({ logger });
} catch (error) {
if (errors.isError(error)) {
logger.error("Error while syncing Keycloak users and groups", {
// Default Error properties:
name: error.name,
cause: error.cause,
message: error.message,
stack: error.stack,
// Additional status code if available:
status: error.response?.status
});
}
}
}
});
};
}
}
function trackProgress(logger) {
let timestamp = Date.now();
let summary;
logger.info("Reading Keycloak users and groups");
function markReadComplete(read) {
summary = `${read.users.length} Keycloak users and ${read.groups.length} Keycloak groups`;
const readDuration = ((Date.now() - timestamp) / 1e3).toFixed(1);
timestamp = Date.now();
logger.info(`Read ${summary} in ${readDuration} seconds. Committing...`);
return { markCommitComplete };
}
function markCommitComplete() {
const commitDuration = ((Date.now() - timestamp) / 1e3).toFixed(1);
logger.info(`Committed ${summary} in ${commitDuration} seconds.`);
}
return { markReadComplete };
}
const keycloakTransformerExtensionPoint = backendPluginApi.createExtensionPoint({
id: "keycloak.transformer"
});
const catalogModuleKeycloakEntityProvider = backendPluginApi.createBackendModule({
pluginId: "catalog",
moduleId: "catalog-backend-module-keycloak",
register(env) {
let userTransformer;
let groupTransformer;
env.registerExtensionPoint(keycloakTransformerExtensionPoint, {
setUserTransformer(transformer) {
if (userTransformer) {
throw new errors.InputError("User transformer may only be set once");
}
userTransformer = transformer;
},
setGroupTransformer(transformer) {
if (groupTransformer) {
throw new errors.InputError("Group transformer may only be set once");
}
groupTransformer = transformer;
}
});
env.registerInit({
deps: {
catalog: alpha.catalogProcessingExtensionPoint,
config: backendPluginApi.coreServices.rootConfig,
logger: backendPluginApi.coreServices.logger,
scheduler: backendPluginApi.coreServices.scheduler
},
async init({ catalog, config, logger, scheduler }) {
catalog.addEntityProvider(
KeycloakOrgEntityProvider.fromConfig(
{ config, logger },
{
scheduler,
schedule: scheduler.createScheduledTaskRunner({
frequency: { minutes: 30 },
timeout: { minutes: 3 }
}),
userTransformer,
groupTransformer
}
)
);
}
});
}
});
exports.KeycloakOrgEntityProvider = KeycloakOrgEntityProvider;
exports.default = catalogModuleKeycloakEntityProvider;
exports.keycloakTransformerExtensionPoint = keycloakTransformerExtensionPoint;
exports.noopGroupTransformer = noopGroupTransformer;
exports.noopUserTransformer = noopUserTransformer;
exports.sanitizeEmailTransformer = sanitizeEmailTransformer;
//# sourceMappingURL=index.cjs.js.map