web: funnel an API down to a single module

**Summary:**

Calls to _retrieve_, _create_, and _update_ Authenticators have been isolated into a module
functions and code accessing those features have been revised to use those functions.

**This commit**

Isolates the Authenticators APIs for `authenticatorsAllList`, `authenticatorsAdminAllList`,
`authenticators<Type>Destroy` and `authenticators<Type>Update` into a connector module, and updates
client code to use them. This eliminates the duplication of `deleteAuthenticatorDevice`, the code
for which was in both *Admin* and *User*, and creates a single source of truth for the DeviceType
strings that identify Device subtypes.

**Details**

One thing that's bothered me a lot is the way our APIs, starting on the Django side, start with a
base model and then "spread out" to incorporate many different inheritance models: different kinds
of Providers, different kinds of Outposts, and different kinds of authentication devices.

In a proper object-oriented system, we'd be granted access to the base class and have the power to
use it effectively as a generic, switchboarding on some RTTI or value type to handle the differences
between the concrete child types. The code generator we use does not provide that base model to UI
clients; "funnelling down" to create a sort of artificial base class on the client and then fanning
back out is an impractical and error-prone exercise.

But we still do a lot of switchboarding, which has three problems:

- Adding subtypes touches a lot of different code
- Distant implementations can miss a specific instance of a subtype
- Repeated use of strings as type handles can introduce spelling errors
- The [last line effect](https://link.springer.com/article/10.1007/s10664-016-9489-6) can introduce
  new and subtle bugs when cut-and-pasting prior examples
- Instances of the *same code* in multiple locations make for maintenance headaches

This patch introduces the concept of **connectors**, modules that provide CRUD functions for common
operations performed on objects of a specific subtype. It is *not* meant to replace concrete class
displays or manipulations, such as (using providers as an example) `ProxyProviderViewPage` or HTML
that is customized for a specific concrete type of provider.

For retrieving lists, deleting instances that can be addressed from the base class, or updating
objects — any place where which call among a collection of isomorphic APIs must be specifieda using
`switch/case` statements — the connector provides a single source of truth for how to recognize the
child types, which `switch/case` statements provide the utility, and what must be done to update
them.
This commit is contained in:
Ken Sternberg 2023-12-22 13:33:23 -08:00
parent 47a1fc62b9
commit afdf269869
5 changed files with 112 additions and 104 deletions

View File

@ -1,5 +1,8 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { deviceTypeName } from "@goauthentik/common/labels";
import {
destroyAuthenticatorDevice,
retrieveAuthenticatorsAdminAllList,
} from "@goauthentik/connectors/authenticators";
import "@goauthentik/elements/forms/DeleteBulkForm";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { Table, TableColumn } from "@goauthentik/elements/table/Table";
@ -8,7 +11,7 @@ import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { AuthenticatorsApi, Device } from "@goauthentik/api";
import { Device } from "@goauthentik/api";
@customElement("ak-user-device-table")
export class UserDeviceTable extends Table<Device> {
@ -18,24 +21,22 @@ export class UserDeviceTable extends Table<Device> {
checkbox = true;
async apiEndpoint(): Promise<PaginatedResponse<Device>> {
return new AuthenticatorsApi(DEFAULT_CONFIG)
.authenticatorsAdminAllList({
user: this.userId,
})
.then((res) => {
return {
pagination: {
count: res.length,
current: 1,
totalPages: 1,
startIndex: 1,
endIndex: res.length,
next: 0,
previous: 0,
},
results: res,
};
});
if (!this.userId) {
throw new Error(`Attempted to retrieve authenticator list for undefined user`);
}
const results = await retrieveAuthenticatorsAdminAllList(this.userId);
return {
pagination: {
count: results.length,
current: 1,
totalPages: 1,
startIndex: 1,
endIndex: results.length,
next: 0,
previous: 0,
},
results,
};
}
columns(): TableColumn[] {
@ -48,25 +49,7 @@ export class UserDeviceTable extends Table<Device> {
}
async deleteWrapper(device: Device) {
const api = new AuthenticatorsApi(DEFAULT_CONFIG);
switch (device.type.toLowerCase()) {
case "authentik_stages_authenticator_duo.duodevice":
return api.authenticatorsAdminDuoDestroy({ id: parseInt(device.pk, 10) });
case "authentik_stages_authenticator_sms.smsdevice":
return api.authenticatorsAdminSmsDestroy({ id: parseInt(device.pk, 10) });
case "authentik_stages_authenticator_totp.totpdevice":
return api.authenticatorsAdminTotpDestroy({ id: parseInt(device.pk, 10) });
case "authentik_stages_authenticator_static.staticdevice":
return api.authenticatorsAdminStaticDestroy({ id: parseInt(device.pk, 10) });
case "authentik_stages_authenticator_webauthn.webauthndevice":
return api.authenticatorsAdminWebauthnDestroy({ id: parseInt(device.pk, 10) });
case "authentik_stages_authenticator_mobile.mobiledevice":
return api.authenticatorsMobileDestroy({
uuid: device.pk,
});
default:
break;
}
return destroyAuthenticatorDevice(device.type, device.pk);
}
renderToolbarSelected(): TemplateResult {

View File

@ -0,0 +1,72 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AuthenticatorsApi, Device } from "@goauthentik/api";
enum DeviceType {
Duo = "authentik_stages_authenticator_duo.duodevice",
SMS = "authentik_stages_authenticator_sms.smsdevice",
TOTP = "authentik_stages_authenticator_totp.totpdevice",
Static = "authentik_stages_authenticator_static.staticdevice",
WebAuthn = "authentik_stages_authenticator_webauthn.webauthndevice",
Mobile = "authentik_stages_authenticator_mobile.mobiledevice",
}
const api = () => new AuthenticatorsApi(DEFAULT_CONFIG);
// prettier-ignore
function exhaustiveGuard(_value: string): never {
throw new Error(
`Authenticator Device List encountered unknown device type: ${JSON.stringify(_value, null, 2,)}`
);
}
export const retrieveAuthenticatorsAllList = () => api().authenticatorsAllList();
export const retrieveAuthenticatorsAdminAllList = (user: number) =>
api().authenticatorsAdminAllList({ user });
export async function destroyAuthenticatorDevice(deviceType: string, id: number | string) {
id = typeof id === "string" ? parseInt(id, 10) : id;
deviceType = deviceType.toLowerCase();
switch (deviceType) {
case DeviceType.Duo:
return api().authenticatorsDuoDestroy({ id });
case DeviceType.SMS:
return api().authenticatorsSmsDestroy({ id });
case DeviceType.TOTP:
return api().authenticatorsTotpDestroy({ id });
case DeviceType.Static:
return api().authenticatorsStaticDestroy({ id });
case DeviceType.WebAuthn:
return api().authenticatorsWebauthnDestroy({ id });
case DeviceType.Mobile:
return api().authenticatorsMobileDestroy({ uuid: `${id}` });
default:
return exhaustiveGuard(deviceType);
}
}
export async function updateAuthenticatorDevice(
deviceType: string,
id: number | string,
device: Device,
) {
id = typeof id === "string" ? parseInt(id, 10) : id;
deviceType = deviceType.toLowerCase();
switch (deviceType) {
case DeviceType.Duo:
return api().authenticatorsDuoUpdate({ id, duoDeviceRequest: device });
case DeviceType.SMS:
return api().authenticatorsSmsUpdate({ id, sMSDeviceRequest: device });
case DeviceType.TOTP:
return api().authenticatorsTotpUpdate({ id, tOTPDeviceRequest: device });
case DeviceType.Static:
return api().authenticatorsStaticUpdate({ id, staticDeviceRequest: device });
case DeviceType.WebAuthn:
return api().authenticatorsWebauthnUpdate({ id, webAuthnDeviceRequest: device });
case DeviceType.Mobile:
return api().authenticatorsMobileUpdate({ uuid: `${id}`, mobileDeviceRequest: device });
default:
return exhaustiveGuard(deviceType);
}
}

View File

@ -1,4 +1,7 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import {
retrieveAuthenticatorsAllList,
updateAuthenticatorDevice,
} from "@goauthentik/connectors/authenticators";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
@ -7,7 +10,7 @@ import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { AuthenticatorsApi, Device } from "@goauthentik/api";
import { Device } from "@goauthentik/api";
@customElement("ak-user-mfa-form")
export class MFADeviceForm extends ModelForm<Device, string> {
@ -15,8 +18,7 @@ export class MFADeviceForm extends ModelForm<Device, string> {
deviceType!: string;
async loadInstance(pk: string): Promise<Device> {
const devices = await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsAllList();
return devices.filter((device) => {
return (await retrieveAuthenticatorsAllList()).filter((device) => {
return device.pk === pk && device.type === this.deviceType;
})[0];
}
@ -26,46 +28,10 @@ export class MFADeviceForm extends ModelForm<Device, string> {
}
async send(device: Device): Promise<Device> {
switch (this.instance?.type.toLowerCase()) {
case "authentik_stages_authenticator_duo.duodevice":
await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsDuoUpdate({
id: parseInt(this.instance?.pk, 10),
duoDeviceRequest: device,
});
break;
case "authentik_stages_authenticator_sms.smsdevice":
await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsSmsUpdate({
id: parseInt(this.instance?.pk, 10),
sMSDeviceRequest: device,
});
break;
case "authentik_stages_authenticator_totp.totpdevice":
await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsTotpUpdate({
id: parseInt(this.instance?.pk, 10),
tOTPDeviceRequest: device,
});
break;
case "authentik_stages_authenticator_static.staticdevice":
await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsStaticUpdate({
id: parseInt(this.instance?.pk, 10),
staticDeviceRequest: device,
});
break;
case "authentik_stages_authenticator_webauthn.webauthndevice":
await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsWebauthnUpdate({
id: parseInt(this.instance?.pk, 10),
webAuthnDeviceRequest: device,
});
break;
case "authentik_stages_authenticator_mobile.mobiledevice":
await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsMobileUpdate({
uuid: this.instance?.pk,
mobileDeviceRequest: device,
});
break;
default:
break;
if (!this.instance) {
return device;
}
await updateAuthenticatorDevice(this.instance.type, this.instance.pk, device);
return device;
}

View File

@ -1,5 +1,9 @@
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AndNext } from "@goauthentik/common/api/config";
import { deviceTypeName } from "@goauthentik/common/labels";
import {
destroyAuthenticatorDevice,
retrieveAuthenticatorsAllList,
} from "@goauthentik/connectors/authenticators";
import "@goauthentik/elements/buttons/Dropdown";
import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/TokenCopyButton";
@ -14,7 +18,7 @@ import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { AuthenticatorsApi, Device, UserSetting } from "@goauthentik/api";
import { Device, UserSetting } from "@goauthentik/api";
export const stageToAuthenticatorName = (stage: UserSetting) =>
stage.title ?? `Invalid stage component ${stage.component}`;
@ -27,7 +31,7 @@ export class MFADevicesPage extends Table<Device> {
checkbox = true;
async apiEndpoint(): Promise<PaginatedResponse<Device>> {
const devices = await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsAllList();
const devices = await retrieveAuthenticatorsAllList();
return {
pagination: {
current: 0,
@ -84,25 +88,7 @@ export class MFADevicesPage extends Table<Device> {
}
async deleteWrapper(device: Device) {
const api = new AuthenticatorsApi(DEFAULT_CONFIG);
switch (device.type.toLowerCase()) {
case "authentik_stages_authenticator_duo.duodevice":
return api.authenticatorsDuoDestroy({ id: parseInt(device.pk, 10) });
case "authentik_stages_authenticator_sms.smsdevice":
return api.authenticatorsSmsDestroy({ id: parseInt(device.pk, 10) });
case "authentik_stages_authenticator_totp.totpdevice":
return api.authenticatorsTotpDestroy({ id: parseInt(device.pk, 10) });
case "authentik_stages_authenticator_static.staticdevice":
return api.authenticatorsStaticDestroy({ id: parseInt(device.pk, 10) });
case "authentik_stages_authenticator_webauthn.webauthndevice":
return api.authenticatorsWebauthnDestroy({ id: parseInt(device.pk, 10) });
case "authentik_stages_authenticator_mobile.mobiledevice":
return api.authenticatorsMobileDestroy({
uuid: device.pk,
});
default:
break;
}
return destroyAuthenticatorDevice(device.type, device.pk);
}
renderToolbarSelected(): TemplateResult {

View File

@ -6,6 +6,7 @@
"@goauthentik/admin/*": ["src/admin/*"],
"@goauthentik/common/*": ["src/common/*"],
"@goauthentik/components/*": ["src/components/*"],
"@goauthentik/connectors/*": ["src/connectors/*"],
"@goauthentik/docs/*": ["../website/docs/*"],
"@goauthentik/elements/*": ["src/elements/*"],
"@goauthentik/flow/*": ["src/flow/*"],