Skip to content

Commit

Permalink
feat: Keycloak migrations (#14)
Browse files Browse the repository at this point in the history
  • Loading branch information
TheSlimvReal authored Nov 7, 2023
1 parent 1d04a18 commit 0906356
Show file tree
Hide file tree
Showing 8 changed files with 181 additions and 4 deletions.
9 changes: 7 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ import { CouchdbAdminController } from './couchdb/couchdb-admin.controller';
import { HttpModule } from '@nestjs/axios';
import { ConfigModule } from '@nestjs/config';
import { CouchdbService } from './couchdb/couchdb.service';
import { KeycloakService } from './couchdb/keycloak.service';
import { KeycloakService } from './keycloak/keycloak.service';
import { MigrationController } from './couchdb/migration.controller';
import { KeycloakMigrationController } from './keycloak/keycloak-migration.controller';

@Module({
imports: [HttpModule, ConfigModule.forRoot({ isGlobal: true })],
controllers: [CouchdbAdminController, MigrationController],
controllers: [
CouchdbAdminController,
MigrationController,
KeycloakMigrationController,
],
providers: [CouchdbService, KeycloakService],
})
export class AppModule {}
2 changes: 1 addition & 1 deletion src/couchdb/couchdb-admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { ApiOperation, ApiQuery } from '@nestjs/swagger';
import * as credentials from 'src/assets/credentials.json';
import { Couchdb, CouchdbService } from './couchdb.service';
import { KeycloakService } from './keycloak.service';
import { KeycloakService } from '../keycloak/keycloak.service';
import { BulkUpdateDto } from './bulk-update.dto';

@Controller('couchdb-admin')
Expand Down
7 changes: 7 additions & 0 deletions src/keycloak/client-config.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Parts of the client configuration as described here {@link https://www.keycloak.org/docs-api/22.0.5/rest-api/index.html#ClientRepresentation}
* It can be retrieved using the realm export functionality.
*/
export class ClientConfig {
id: string;
}
18 changes: 18 additions & 0 deletions src/keycloak/keycloak-migration.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { KeycloakMigrationController } from './keycloak-migration.controller';

describe('KeycloakMigrationController', () => {
let controller: KeycloakMigrationController;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [KeycloakMigrationController],
}).compile();

controller = module.get<KeycloakMigrationController>(KeycloakMigrationController);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});
});
135 changes: 135 additions & 0 deletions src/keycloak/keycloak-migration.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { Body, Controller, Post } from '@nestjs/common';
import { KeycloakService } from './keycloak.service';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom, map } from 'rxjs';
import { RealmConfig } from './realm-config.dto';
import { ClientConfig } from './client-config.dto';
import { ApiOperation } from '@nestjs/swagger';

@Controller('keycloak-migration')
export class KeycloakMigrationController {
private config;
constructor(private keycloak: KeycloakService, private http: HttpService) {}

@ApiOperation({
description: `
Updates all non-master realms with the new provided config.
This currently only includes the top level realm config as well as the client scopes.
Some things like authentication flows are not supported and need to be migrated manually.
`,
})
@Post('realms')
migrateRealms(@Body() realmConfig: RealmConfig) {
return this.runForAllRealms((realm) =>
this.updateRealm(realm, realmConfig),
);
}

@ApiOperation({
description: `
Update the 'app' client of each non-master realm with the provided config.
This is done by deleting the existing client and creating a new one with the new config.
All custom configuration of the client will be lost.
`,
})
@Post('clients')
migrateClients(@Body() clientConfig: ClientConfig) {
return this.runForAllRealms((realm) =>
this.updateClient(realm, clientConfig),
);
}

private async runForAllRealms(func: (realm: string) => Promise<any>) {
const token = await this.keycloak.getKeycloakToken();
this.config = { headers: { Authorization: 'Bearer ' + token } };
const realms = await this.getResource<RealmConfig[]>();
const results = realms
.map(({ realm }) => realm)
.filter((realm) => realm !== 'master')
.map((realm) => func(realm));
return Promise.all(results);
}

private async updateRealm(realm: string, realmConfig: RealmConfig) {
const currentConfig = await this.createResource<RealmConfig>(
`${realm}/partial-export`,
);
await this.updateResource(realm, realmConfig);
await this.alignResources(
realmConfig.clientScopes,
currentConfig.clientScopes,
'name',
`${realm}/client-scopes`,
);
}

private updateClient(realm: string, clientConfig: ClientConfig) {
const clientPath = `${realm}/clients`;
return this.getResource<ClientConfig[]>(`${clientPath}?clientId=app`)
.then(([client]) => this.deleteResource(`${clientPath}/${client.id}`))
.then(() => this.createResource(clientPath, clientConfig));
}

/**
* Aligns realm resource from config with actual on server
* TODO deleting and updating is not supported yet. Only creating new.
* @private
*/
private alignResources<T, P extends keyof T>(
update: T[],
existing: T[],
property: P,
path: string,
) {
const missingResources = update.filter(
(r) => !existing.some((c) => c[property] === r[property]),
);
return Promise.all(
missingResources.map((res) => this.createResource(path, res)),
);
}

private createResource<T = any>(path: string, resource?) {
return firstValueFrom(
this.http
.post<T>(
`${this.keycloak.keycloakUrl}/admin/realms/${path}`,
resource,
this.config,
)
.pipe(map((res) => res.data)),
);
}

private updateResource<T = any>(path: string, resource?: any) {
return firstValueFrom(
this.http
.put<T>(
`${this.keycloak.keycloakUrl}/admin/realms/${path}`,
resource,
this.config,
)
.pipe(map((res) => res.data)),
);
}

private getResource<T = any>(path = '') {
return firstValueFrom(
this.http
.get<T>(
`${this.keycloak.keycloakUrl}/admin/realms/${path}`,
this.config,
)
.pipe(map((res) => res.data)),
);
}

private deleteResource(path: string) {
return firstValueFrom(
this.http.delete(
`${this.keycloak.keycloakUrl}/admin/realms/${path}`,
this.config,
),
);
}
}
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ConfigService } from '@nestjs/config';
@Injectable()
export class KeycloakService {
private keycloakPassword = this.configService.get('KEYCLOAK_ADMIN_PASSWORD');
private keycloakUrl = this.configService.get('KEYCLOAK_URL');
keycloakUrl = this.configService.get('KEYCLOAK_URL');
constructor(
private http: HttpService,
private configService: ConfigService,
Expand Down
12 changes: 12 additions & 0 deletions src/keycloak/realm-config.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Parts of the realm config as described here {@link https://www.keycloak.org/docs-api/22.0.5/rest-api/index.html#RealmRepresentation}
* It can be retrieved using the realm export functionality.
*/
export class RealmConfig {
realm: string;
clientScopes: ClientScope[];
}

class ClientScope {
name: string;
}

0 comments on commit 0906356

Please sign in to comment.