Skip to content

Commit fc9e1be

Browse files
authored
Add warnings to FGA query and check responses (#1280)
## Description Adds warnings field to the FGA check and query responses. Warning types include: BaseWarning - catch all for warnings not supported by the current SDK version MissingContextKeysWarning - missing context keys found while evaluating policies in the check / query request Note: Query response returns "warnings" as a top-level field in the Paginate response. To avoid affecting other packages, I extended the AutoPaginate class in FGA package and added a custom deserializer.
1 parent 4151cb7 commit fc9e1be

File tree

11 files changed

+243
-6
lines changed

11 files changed

+243
-6
lines changed

src/common/utils/pagination.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export class AutoPaginatable<T> {
55
readonly options: PaginationOptions;
66

77
constructor(
8-
private list: List<T>,
8+
protected list: List<T>,
99
private apiCall: (params: PaginationOptions) => Promise<List<T>>,
1010
options?: PaginationOptions,
1111
) {

src/fga/fga.spec.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,57 @@ describe('FGA', () => {
4444
warrantToken: 'abc',
4545
});
4646
});
47+
48+
it('deserializes warnings in check response', async () => {
49+
fetchOnce({
50+
result: 'authorized',
51+
is_implicit: false,
52+
warrant_token: 'abc',
53+
warnings: [
54+
{
55+
code: 'missing_context_keys',
56+
message: 'Missing required context keys',
57+
keys: ['tenant_id', 'region'],
58+
},
59+
{
60+
code: 'some_other_warning',
61+
message: 'Some other warning message',
62+
},
63+
],
64+
});
65+
const checkResult = await workos.fga.check({
66+
checks: [
67+
{
68+
resource: {
69+
resourceType: 'role',
70+
resourceId: 'admin',
71+
},
72+
relation: 'member',
73+
subject: {
74+
resourceType: 'user',
75+
resourceId: 'user_123',
76+
},
77+
},
78+
],
79+
});
80+
expect(fetchURL()).toContain('/fga/v1/check');
81+
expect(checkResult).toMatchObject({
82+
result: 'authorized',
83+
isImplicit: false,
84+
warrantToken: 'abc',
85+
warnings: [
86+
{
87+
code: 'missing_context_keys',
88+
message: 'Missing required context keys',
89+
keys: ['tenant_id', 'region'],
90+
},
91+
{
92+
code: 'some_other_warning',
93+
message: 'Some other warning message',
94+
},
95+
],
96+
});
97+
});
4798
});
4899

49100
describe('createResource', () => {
@@ -618,6 +669,101 @@ describe('FGA', () => {
618669
]);
619670
});
620671

672+
it('deserializes warnings in query response', async () => {
673+
fetchOnce({
674+
data: [
675+
{
676+
resource_type: 'role',
677+
resource_id: 'admin',
678+
warrant: {
679+
resource_type: 'role',
680+
resource_id: 'admin',
681+
relation: 'member',
682+
subject: {
683+
resource_type: 'user',
684+
resource_id: 'user_123',
685+
},
686+
},
687+
is_implicit: false,
688+
},
689+
{
690+
resource_type: 'role',
691+
resource_id: 'manager',
692+
warrant: {
693+
resource_type: 'role',
694+
resource_id: 'manager',
695+
relation: 'member',
696+
subject: {
697+
resource_type: 'user',
698+
resource_id: 'user_123',
699+
},
700+
},
701+
is_implicit: true,
702+
},
703+
],
704+
list_metadata: {
705+
before: null,
706+
after: null,
707+
},
708+
warnings: [
709+
{
710+
code: 'missing_context_keys',
711+
message: 'Missing required context keys',
712+
keys: ['tenant_id'],
713+
},
714+
{
715+
code: 'some_other_warning',
716+
message: 'Some other warning message',
717+
},
718+
],
719+
});
720+
const result = await workos.fga.query({
721+
q: 'select role where user:user_123 is member',
722+
});
723+
expect(fetchURL()).toContain('/fga/v1/query');
724+
expect(result.data).toMatchObject([
725+
{
726+
resourceType: 'role',
727+
resourceId: 'admin',
728+
warrant: {
729+
resourceType: 'role',
730+
resourceId: 'admin',
731+
relation: 'member',
732+
subject: {
733+
resourceType: 'user',
734+
resourceId: 'user_123',
735+
},
736+
},
737+
isImplicit: false,
738+
},
739+
{
740+
resourceType: 'role',
741+
resourceId: 'manager',
742+
warrant: {
743+
resourceType: 'role',
744+
resourceId: 'manager',
745+
relation: 'member',
746+
subject: {
747+
resourceType: 'user',
748+
resourceId: 'user_123',
749+
},
750+
},
751+
isImplicit: true,
752+
},
753+
]);
754+
expect(result.warnings).toMatchObject([
755+
{
756+
code: 'missing_context_keys',
757+
message: 'Missing required context keys',
758+
keys: ['tenant_id'],
759+
},
760+
{
761+
code: 'some_other_warning',
762+
message: 'Some other warning message',
763+
},
764+
]);
765+
});
766+
621767
it('sends correct params and options', async () => {
622768
fetchOnce({
623769
data: [

src/fga/fga.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ import {
2929
} from './interfaces';
3030
import {
3131
deserializeBatchWriteResourcesResponse,
32-
deserializeQueryResult,
3332
deserializeResource,
3433
deserializeWarrant,
3534
deserializeWarrantToken,
35+
deserializeQueryResult,
3636
serializeBatchWriteResourcesOptions,
3737
serializeCheckBatchOptions,
3838
serializeCheckOptions,
@@ -45,6 +45,8 @@ import {
4545
import { isResourceInterface } from './utils/interface-check';
4646
import { AutoPaginatable } from '../common/utils/pagination';
4747
import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';
48+
import { FgaPaginatable } from './utils/fga-paginatable';
49+
import { fetchAndDeserializeFGAList } from './utils/fetch-and-deserialize-list';
4850

4951
export class FGA {
5052
constructor(private readonly workos: WorkOS) {}
@@ -208,17 +210,17 @@ export class FGA {
208210
async query(
209211
options: QueryOptions,
210212
requestOptions: QueryRequestOptions = {},
211-
): Promise<AutoPaginatable<QueryResult>> {
212-
return new AutoPaginatable(
213-
await fetchAndDeserialize<QueryResultResponse, QueryResult>(
213+
): Promise<FgaPaginatable<QueryResult>> {
214+
return new FgaPaginatable<QueryResult>(
215+
await fetchAndDeserializeFGAList<QueryResultResponse, QueryResult>(
214216
this.workos,
215217
'/fga/v1/query',
216218
deserializeQueryResult,
217219
serializeQueryOptions(options),
218220
requestOptions,
219221
),
220222
(params) =>
221-
fetchAndDeserialize<QueryResultResponse, QueryResult>(
223+
fetchAndDeserializeFGAList<QueryResultResponse, QueryResult>(
222224
this.workos,
223225
'/fga/v1/query',
224226
deserializeQueryResult,

src/fga/interfaces/check.interface.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { PolicyContext, SerializedSubject, Subject } from './warrant.interface';
33
import { CheckOp } from './check-op.enum';
44
import { PostOptions } from '../../common/interfaces';
55
import { deserializeDecisionTreeNode } from '../serializers/check-options.serializer';
6+
import { Warning } from './warning.interface';
67

78
const CHECK_RESULT_AUTHORIZED = 'authorized';
89

@@ -49,6 +50,7 @@ export interface CheckResultResponse {
4950
is_implicit: boolean;
5051
warrant_token: string;
5152
debug_info?: DebugInfoResponse;
53+
warnings?: Warning[];
5254
}
5355

5456
export interface DebugInfo {
@@ -82,13 +84,15 @@ export interface CheckResultInterface {
8284
isImplicit: boolean;
8385
warrantToken: string;
8486
debugInfo?: DebugInfo;
87+
warnings?: Warning[];
8588
}
8689

8790
export class CheckResult implements CheckResultInterface {
8891
public result: string;
8992
public isImplicit: boolean;
9093
public warrantToken: string;
9194
public debugInfo?: DebugInfo;
95+
public warnings?: Warning[];
9296

9397
constructor(json: CheckResultResponse) {
9498
this.result = json.result;
@@ -102,6 +106,7 @@ export class CheckResult implements CheckResultInterface {
102106
),
103107
}
104108
: undefined;
109+
this.warnings = json.warnings;
105110
}
106111

107112
isAuthorized(): boolean {

src/fga/interfaces/list.interface.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { List, ListResponse } from '../../common/interfaces';
2+
import { Warning } from './warning.interface';
3+
4+
export interface FGAListResponse<T> extends ListResponse<T> {
5+
warnings?: Warning[];
6+
}
7+
8+
export interface FGAList<T> extends List<T> {
9+
warnings?: Warning[];
10+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export interface BaseWarning {
2+
code: string;
3+
message: string;
4+
}
5+
6+
export interface MissingContextKeysWarning extends BaseWarning {
7+
code: 'missing_context_keys';
8+
keys: string[];
9+
}
10+
11+
export type Warning = BaseWarning | MissingContextKeysWarning;

src/fga/serializers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export * from './resource.serializer';
1010
export * from './warrant-token.serializer';
1111
export * from './warrant.serializer';
1212
export * from './write-warrant-options.serializer';
13+
export * from './list.serializer';
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { FGAList } from '../interfaces/list.interface';
2+
import { ListResponse } from '../../common/interfaces';
3+
4+
export const deserializeFGAList = <T, U>(
5+
response: ListResponse<T> & { warnings?: any[] },
6+
deserializeFn: (data: T) => U,
7+
): FGAList<U> => ({
8+
object: 'list',
9+
data: response.data.map(deserializeFn),
10+
listMetadata: response.list_metadata,
11+
warnings: response.warnings,
12+
});

src/fga/serializers/query-result.serializer.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
import { QueryResult, QueryResultResponse } from '../interfaces';
2+
import { Warning } from '../interfaces/warning.interface';
3+
import { ListResponse } from '../../common/interfaces';
4+
5+
export interface QueryResultListResponse
6+
extends ListResponse<QueryResultResponse> {
7+
warnings?: Warning[];
8+
}
29

310
export const deserializeQueryResult = (
411
queryResult: QueryResultResponse,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { WorkOS } from '../../workos';
2+
import { FGAList } from '../interfaces/list.interface';
3+
import { QueryRequestOptions } from '../interfaces';
4+
import { PaginationOptions } from '../../common/interfaces';
5+
import { ListResponse } from '../../common/interfaces';
6+
import { deserializeFGAList } from '../serializers/list.serializer';
7+
8+
export const fetchAndDeserializeFGAList = async <T, U>(
9+
workos: WorkOS,
10+
endpoint: string,
11+
deserializeFn: (data: T) => U,
12+
options?: PaginationOptions,
13+
requestOptions?: QueryRequestOptions,
14+
): Promise<FGAList<U>> => {
15+
const { data: response } = await workos.get<
16+
ListResponse<T> & { warnings?: any[] }
17+
>(endpoint, {
18+
query: options,
19+
...requestOptions,
20+
});
21+
22+
return deserializeFGAList(response, deserializeFn);
23+
};

src/fga/utils/fga-paginatable.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { AutoPaginatable } from '../../common/utils/pagination';
2+
import { FGAList } from '../interfaces/list.interface';
3+
import { Warning } from '../interfaces/warning.interface';
4+
import { PaginationOptions } from '../../common/interfaces';
5+
6+
export class FgaPaginatable<T> extends AutoPaginatable<T> {
7+
protected override list!: FGAList<T>;
8+
9+
constructor(
10+
list: FGAList<T>,
11+
apiCall: (params: PaginationOptions) => Promise<FGAList<T>>,
12+
options?: PaginationOptions,
13+
) {
14+
super(list, apiCall, options);
15+
}
16+
17+
get warnings(): Warning[] | undefined {
18+
return this.list.warnings;
19+
}
20+
}

0 commit comments

Comments
 (0)