diff --git a/.gitignore b/.gitignore index 442d816..ec2c7cc 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,5 @@ apps/dashboard/common_types.tsbuildinfo *.log junit.xml .env.test.github-action + +*.swp diff --git a/src/audit/index.ts b/src/audit/index.ts new file mode 100644 index 0000000..6f88412 --- /dev/null +++ b/src/audit/index.ts @@ -0,0 +1,12 @@ +import { Edlink } from '..'; +import { AuditEvents } from '../common'; +import { BearerTokenAPI, TokenSet } from '../types'; + +export class Audit extends BearerTokenAPI { + public events: AuditEvents; + + constructor(edlink: Edlink, token_set: TokenSet) { + super(edlink, token_set); + this.events = new AuditEvents(this); + } +} diff --git a/src/common/audit_event.ts b/src/common/audit_event.ts new file mode 100644 index 0000000..bed6b8d --- /dev/null +++ b/src/common/audit_event.ts @@ -0,0 +1,45 @@ +import { Actor, AuditEvent, BearerTokenAPI, Context, RequestOptionsGet, RequestOptionsPaging, RequestOptionsPost, Scope, Target } from '../types'; + +export class AuditEvents { + constructor(private api: BearerTokenAPI) { + if (api.api !== 'audit') { + throw new Error('The Audit Logs API only works with the Application TokenSetType'); + } + } + + /** + * Paginates through all Events within a given scope. + * @param scope_type The type of scope, either `integration` or `institution` + * @param scope_id The UUID of the scope + * @param options Provide a `limit` for the max number of results + */ + async *list(scope_type: string, scope_id: string, options: RequestOptionsPaging = {}): AsyncGenerator { + yield* this.api.paginate(`/events/${scope_type}/${scope_id}`, options); + } + + /** + * Fetches a single Event by ID. + * @param event_id The UUID of the Event + * @returns The requested Event + */ + fetch(event_id: string, options: RequestOptionsGet = {}): Promise { + return this.api.request(`/events/${event_id}`, options); + } + + /** + * Records a new Event. + * @param event The Event body to record + * @param options Optional request options + * @returns The recorded Event's assigned ID + */ + create( + event: { actor: Actor; action: string; targets: Target[]; scope: Scope; context?: Context; data?: any }, + options: RequestOptionsPost = {}, + ): Promise> { + return this.api.request({ + url: '/events', + method: 'POST', + data: event, + }, options); + } +} diff --git a/src/common/index.ts b/src/common/index.ts index 4131657..e1762b2 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -1,3 +1,4 @@ +export { AuditEvents } from './audit_event'; export { Agents } from './agents'; export { Classes } from './classes'; export { Courses } from './courses'; @@ -22,4 +23,4 @@ export function serialize(object: Record) { } return str.join('&'); -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index c738990..f9cd804 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { serialize } from './common'; +import { Audit } from './audit'; import { Graph } from './graph'; -import { IntegrationTokenSet, PersonTokenSet, TokenSetType } from './types'; +import { ApplicationTokenSet, IntegrationTokenSet, PersonTokenSet, TokenSetType } from './types'; import { User } from './user'; import { Auth } from './user/auth'; @@ -44,10 +45,18 @@ export class Edlink { * @returns {User} An instance of the Edlink User API interface using the provided TokenSet */ public use(token_set: PersonTokenSet): User; + /** + * Initialize an instance of the Edlink Audit API with an application token set. + * @param token_set The ApplicationTokenSet used to authenticate the request + * @returns {Audit} An instance of the Edlink Audit API interface using the provided TokenSet + */ + public use(token_set: ApplicationTokenSet): Audit; - public use(token_set: PersonTokenSet | IntegrationTokenSet): User | Graph { + public use(token_set: PersonTokenSet | IntegrationTokenSet | ApplicationTokenSet): User | Graph | Audit { if (token_set.type === TokenSetType.Person) { return new User(this, token_set); + } else if (token_set.type === TokenSetType.Application) { + return new Audit(this, token_set); } else { return new Graph(this, token_set); } diff --git a/src/types/api.ts b/src/types/api.ts index 575b0f0..bf9d470 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -3,6 +3,8 @@ import { Edlink } from '..'; import { deepDefaults } from '../utils'; import { RequestOptions, TokenSet, TokenSetType } from './common'; +const EDLINK_URL = process.env.ALTERNATE_EDLINK_URL ?? 'https://ed.link'; + export type RequestConfig = { url: string; method: string; @@ -26,14 +28,19 @@ class EdlinkError extends Error { export class BearerTokenAPI { private token_set: TokenSet; private version: number; - private api: 'graph' | 'my'; + public api: 'graph' | 'my' | 'audit'; public edlink: Edlink; constructor(edlink: Edlink, token_set: TokenSet) { // Assign config this.token_set = token_set; this.version = edlink.version; - this.api = token_set.type === TokenSetType.Integration ? 'graph' : 'my'; + this.api = 'graph';// default for TokenSetType.Integration + if (token_set.type === TokenSetType.Application) { + this.api = 'audit'; + } else if (token_set.type === TokenSetType.Person) { + this.api = 'my'; + } this.edlink = edlink; } @@ -63,7 +70,7 @@ export class BearerTokenAPI { // Set url const formattedUrl = new URL(url.startsWith('http') ? url - : `https://ed.link/api/v${this.version}/${this.api}${url}`); + : `${EDLINK_URL}/api/v${this.version}/${this.api}${url}`); // Add formatted options to the URL for (const [key, value] of Object.entries(formatted_options)) { @@ -176,7 +183,7 @@ export class BearerTokenAPI { } private requiresTokenRefresh(tokens: TokenSet = this.token_set): boolean { - if (tokens.type === TokenSetType.Integration) { + if (tokens.type === TokenSetType.Integration || tokens.type === TokenSetType.Application) { return false; } diff --git a/src/types/audit_event.ts b/src/types/audit_event.ts new file mode 100644 index 0000000..b7877bf --- /dev/null +++ b/src/types/audit_event.ts @@ -0,0 +1,126 @@ +/** + * @export + * @enum {string} + */ +export enum CRUDXType { + Create = "create", + Read = "read", + Update = "update", + Delete = "delete", + Other = "other", +} +type UUID = `${string}-${string}-${string}-${string}-${string}`; + +export enum ScopeType { + Institution = "institution", + Integration = "integration", +} + +export interface AuditIdentifier { + value: string; + issuer: string; +} + +export interface Actor { + type: "person" | "system" | "external"; + identifiers: AuditIdentifier[]; + details?: object; +} + +export interface Target { + type: string; + identifiers: AuditIdentifier[]; + details?: object; +} + +export interface Scope { + id: UUID; + type: ScopeType; +} + +export interface SchemaRef { + id: string; + version: string; +} + +export interface Context { + source?: "client" | "server"; + http_method?: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH"; + http_status?: number; + path?: string; + ip?: string; + query?: string; + user_agent?: string; + hostname?: string; + os?: string; + environment?: string; + trigger?: "person" | "system" | "external"; + deployment_id?: string; +} + +/** + * @export + * @interface AuditEvent + */ +export interface AuditEvent { + /** + * Read-only. Assigned by Edlink when the Event is recorded. + * @type {string} + * @memberof AuditEvent + */ + id?: string; + /** + * Read-only. The timestamp at which Edlink received the Event. + * @type {string} + * @memberof AuditEvent + */ + created_date?: string; + /** + * Read-only. The Schema that validated this Event, if any. + * @type {SchemaRef} + * @memberof AuditEvent + */ + schema?: SchemaRef; + /** + * The entity that performed the action. + * @type {Actor} + * @memberof AuditEvent + */ + actor: Actor; + /** + * The customer-defined identifier for the action, e.g. `assignment.submit`. + * @type {string} + * @memberof AuditEvent + */ + action: string; + /** + * Read-only. The CRUD classification of the action, derived from the matching Schema. + * @type {CRUDXType} + * @memberof AuditEvent + */ + action_type?: CRUDXType; + /** + * The entities affected by the action. + * @type {Array} + * @memberof AuditEvent + */ + targets: Target[]; + /** + * The scope (integration or institution) the Event belongs to. + * @type {Scope} + * @memberof AuditEvent + */ + scope: Scope; + /** + * Information about the underlying network request. Strongly suggested. + * @type {Context} + * @memberof AuditEvent + */ + context?: Context; + /** + * Arbitrary data relevant to the Event. Validated only if a Schema defines a data shape. + * @type {any} + * @memberof AuditEvent + */ + data?: any; +} diff --git a/src/types/common.ts b/src/types/common.ts index 878e4cd..809dc22 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -188,7 +188,15 @@ export type IntegrationTokenSet = BaseTokenSet & { access_token: string; }; -export type TokenSet = PersonTokenSet | IntegrationTokenSet; +export type ApplicationTokenSet = BaseTokenSet & { + type: TokenSetType.Application; + access_token: string; +}; + +export type TokenSet = + | PersonTokenSet + | IntegrationTokenSet + | ApplicationTokenSet; export type RequestOptionsPaging = { limit?: number; @@ -273,4 +281,4 @@ export type Source = { name: string; application: string; } -}; \ No newline at end of file +}; diff --git a/src/types/index.ts b/src/types/index.ts index 2942cfe..48417de 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,6 @@ export * from './api'; +export * from './audit_schema'; +export * from './audit_event'; export * from './common'; export * from './address'; export * from './agent'; @@ -17,4 +19,4 @@ export * from './category'; export * from './submission'; export * from './attachment'; export * from './license'; -export * from './product'; \ No newline at end of file +export * from './product'; diff --git a/test/main.test.ts b/test/main.test.ts index a96ddcc..48c33ff 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -11,6 +11,9 @@ import { Person, TokenSetType, IntegrationTokenSet, + ApplicationTokenSet, + ScopeType, + CRUDXType, Agent, PersonTokenSet, } from '../src'; @@ -30,8 +33,6 @@ const edlink = new Edlink({ log_level: 'silent' }); -// jest.setTimeout(10000); -`` describe('User', () => { it('auth', async () => { // const grant = await edlink.auth.grant({ @@ -443,6 +444,177 @@ describe('Request Options', () => { }); }); +if (process.env.APPLICATION_SECRET_KEY) { + const application_token_set: ApplicationTokenSet = { + access_token: process.env.APPLICATION_SECRET_KEY!, + type: TokenSetType.Application + }; + + describe('Audit', () => { + let created_event_id: string; + let created_schema_id: string; + let created_schema_action: string; + let created_schema_event_id: string; + + it('POST /api/v2/audit/events', async () => { + const result = await edlink.use(application_token_set).events.create({ + actor: { + type: 'person', + identifiers: [{ value: 'test_user_1', issuer: 'test' }] + }, + action: 'user.login', + targets: [ + { type: 'session', identifiers: [{ value: 'session_abc', issuer: 'test' }] } + ], + scope: { + id: process.env.AUDIT_SCOPE_ID! as `${string}-${string}-${string}-${string}-${string}`, + type: ScopeType.Integration + }, + context: { + http_method: 'POST', + path: '/login', + ip: '1.2.3.4', + user_agent: 'Jest' + }, + data: { + internal_user_id: 'test_user_1', + application_name: 'Test Suite' + } + }); + expect(result).toBeDefined(); + expect(result.id).toBeDefined(); + created_event_id = result.id!; + await new Promise((r) => setTimeout(r, 3000)); // must wait for async_inserts to clickhouse to work + }); + + it('GET /api/v2/audit/events/:event_id', async () => { + const event = await edlink.use(application_token_set).events.fetch(created_event_id); + expect(event).toBeDefined(); + expect(event.id).toBe(created_event_id); + expect(event.action).toBe('user.login'); + expect(event.actor).toBeDefined(); + expect(event.actor.type).toBe('person'); + expect(event.targets).toBeDefined(); + expect(Array.isArray(event.targets)).toBe(true); + expect(event.scope).toBeDefined(); + expect(event.created_date).toBeDefined(); + }); + + it('GET /api/v2/audit/events/:scope_type/:scope_id', async () => { + let found = false; + for await (const event of edlink.use(application_token_set).events.list('integration', process.env.AUDIT_SCOPE_ID!, { limit: 25 })) { + expect(event).toBeDefined(); + expect(event.id).toBeDefined(); + expect(event.action).toBeDefined(); + if (event.id === created_event_id) { + found = true; + } + } + expect(found).toBe(true); + }); + + it('POST /api/v2/audit/schemas', async () => { + created_schema_action = `test.schema.${Date.now()}`; + const schema = await edlink.use(application_token_set).schemas.create( + { id: created_schema_action, type: CRUDXType.Create }, + { + type: 'object', + properties: { + test_field: { type: 'string', description: 'A test field' } + }, + required: ['test_field'] + }, + 'lax' + ); + expect(schema).toBeDefined(); + expect(schema.id).toBeDefined(); + expect(schema.action.id).toBe(created_schema_action); + expect(schema.action.type).toBe(CRUDXType.Create); + expect(schema.validation_level).toBe('lax'); + created_schema_id = schema.id; + }); + + it('GET /api/v2/audit/schemas/:schema_id_or_action (by id)', async () => { + const schema = await edlink.use(application_token_set).schemas.fetch(created_schema_id); + expect(schema).toBeDefined(); + expect(schema.id).toBe(created_schema_id); + expect(schema.action.id).toBe(created_schema_action); + expect(schema.action.type).toBe(CRUDXType.Create); + expect(schema.validation_level).toBe('lax'); + }); + + it('GET /api/v2/audit/schemas/:schema_id_or_action (by action)', async () => { + const schema = await edlink.use(application_token_set).schemas.fetch(created_schema_action); + expect(schema).toBeDefined(); + expect(schema.id).toBe(created_schema_id); + expect(schema.action.id).toBe(created_schema_action); + }); + + it('PATCH /api/v2/audit/schemas', async () => { + const schema = await edlink.use(application_token_set).schemas.update( + created_schema_action, + { + action_type: CRUDXType.Other, + validation_level: 'strict', + data: { + type: 'object', + properties: { + test_field: { type: 'string', description: 'A test field' }, + extra_field: { type: 'number', description: 'An extra field' } + }, + required: ['test_field'] + } + } + ); + expect(schema).toBeDefined(); + expect(schema.id).toBe(created_schema_id); + expect(schema.action.id).toBe(created_schema_action); + expect(schema.action.type).toBe(CRUDXType.Other); + expect(schema.validation_level).toBe('strict'); + }); + + it('POST /api/v2/audit/events (with schema)', async () => { + const result = await edlink.use(application_token_set).events.create({ + actor: { + type: 'system', + identifiers: [{ value: 'test_system_1', issuer: 'test' }] + }, + action: created_schema_action, + targets: [ + { type: 'resource', identifiers: [{ value: 'resource_xyz', issuer: 'test' }] } + ], + scope: { + id: process.env.AUDIT_SCOPE_ID! as `${string}-${string}-${string}-${string}-${string}`, + type: ScopeType.Integration + }, + data: { test_field: 'hello from schema test' } + }); + expect(result).toBeDefined(); + expect(result.id).toBeDefined(); + created_schema_event_id = result.id!; + await new Promise((r) => setTimeout(r, 3000)); + }); + + it('GET /api/v2/audit/events/:event_id (schema id present)', async () => { + const event = await edlink.use(application_token_set).events.fetch(created_schema_event_id); + expect(event).toBeDefined(); + expect(event.id).toBe(created_schema_event_id); + expect(event.action).toBe(created_schema_action); + expect(event.schema).toBeDefined(); + expect(event.schema?.id).toBe(created_schema_id); + }); + + it('GET /api/v2/audit/events/:scope_type/:scope_id with action filter', async () => { + for await (const event of edlink.use(application_token_set).events.list('integration', process.env.AUDIT_SCOPE_ID!, { limit: 5, filter: { action: [{operator: 'equals', value: 'user.login' }]} })) { + expect(event.action).toBe('user.login'); + } + }); + }); +} else { + console.error('APPLICATION_SECRET_KEY is not set, skipping Audit tests'); +} + +if (process.env.REFRESH_TOKEN) { describe('Categories', () => { it('should list categories for a class', async () => { const refresh = await edlink.auth.refresh(process.env.REFRESH_TOKEN!); @@ -506,4 +678,7 @@ describe('Categories', () => { expect(response).toBeDefined(); } }); -}); \ No newline at end of file +}); +} else { + console.error('REFRESH_TOKEN is not set, skipping tests that require it'); +}