chenhunghan commited on
Commit
1646a56
·
unverified ·
1 Parent(s): d791e39

feat: add masking

Browse files

Signed-off-by: Hung-Han (Henry) Chen <[email protected]>

src/resources/(groups)/[groupId]/index.ts CHANGED
@@ -3,6 +3,7 @@ import { headers } from "xmcp/headers";
3
  import { z } from "zod";
4
  import { getScimBaseUrl } from "../../../utils/getSCIMBaseUrl";
5
  import { getScimToken } from "../../../utils/getSCIMToken";
 
6
 
7
  export const schema = {
8
  groupId: z.string().describe("The ID of the group"),
@@ -18,6 +19,13 @@ export const schema = {
18
  .describe(
19
  "Comma-separated list of attribute names to exclude from the response. Per RFC 7644 Section 3.9"
20
  ),
 
 
 
 
 
 
 
21
  };
22
 
23
  export const metadata: ResourceMetadata = {
@@ -30,6 +38,7 @@ export default async function handler({
30
  groupId,
31
  attributes,
32
  excludedAttributes,
 
33
  }: InferSchema<typeof schema>) {
34
  const requestHeaders = headers();
35
  const apiToken = getScimToken(requestHeaders);
@@ -65,7 +74,12 @@ export default async function handler({
65
  throw new Error(await response.text());
66
  }
67
 
68
- const data = await response.json();
 
 
 
 
 
69
 
70
  return {
71
  contents: [
 
3
  import { z } from "zod";
4
  import { getScimBaseUrl } from "../../../utils/getSCIMBaseUrl";
5
  import { getScimToken } from "../../../utils/getSCIMToken";
6
+ import { maskPII, PII_FIELDS } from "../../../utils/piiMasking";
7
 
8
  export const schema = {
9
  groupId: z.string().describe("The ID of the group"),
 
19
  .describe(
20
  "Comma-separated list of attribute names to exclude from the response. Per RFC 7644 Section 3.9"
21
  ),
22
+ piiMasking: z
23
+ .boolean()
24
+ .optional()
25
+ .default(true)
26
+ .describe(
27
+ "Enable PII masking for sensitive fields including member information (display names, references). When true, values are partially masked while maintaining readability. Default: true"
28
+ ),
29
  };
30
 
31
  export const metadata: ResourceMetadata = {
 
38
  groupId,
39
  attributes,
40
  excludedAttributes,
41
+ piiMasking = true,
42
  }: InferSchema<typeof schema>) {
43
  const requestHeaders = headers();
44
  const apiToken = getScimToken(requestHeaders);
 
74
  throw new Error(await response.text());
75
  }
76
 
77
+ let data = await response.json();
78
+
79
+ // Apply PII masking if enabled
80
+ if (piiMasking) {
81
+ data = maskPII(data, PII_FIELDS);
82
+ }
83
 
84
  return {
85
  contents: [
src/resources/(groups)/index.ts CHANGED
@@ -3,6 +3,7 @@ import { headers } from "xmcp/headers";
3
  import { z } from "zod";
4
  import { getScimBaseUrl } from "../../utils/getSCIMBaseUrl";
5
  import { getScimToken } from "../../utils/getSCIMToken";
 
6
 
7
  export const schema = {
8
  filter: z
@@ -23,6 +24,13 @@ export const schema = {
23
  .describe(
24
  "Non-negative integer. Specifies the desired maximum number of query results per page."
25
  ),
 
 
 
 
 
 
 
26
  };
27
 
28
  export const metadata: ResourceMetadata = {
@@ -35,6 +43,7 @@ export default async function handler({
35
  filter,
36
  startIndex,
37
  count,
 
38
  }: InferSchema<typeof schema>) {
39
  const requestHeaders = headers();
40
  const apiToken = getScimToken(requestHeaders);
@@ -71,7 +80,12 @@ export default async function handler({
71
  throw new Error(await response.text());
72
  }
73
 
74
- const data = await response.json();
 
 
 
 
 
75
 
76
  return {
77
  contents: [
 
3
  import { z } from "zod";
4
  import { getScimBaseUrl } from "../../utils/getSCIMBaseUrl";
5
  import { getScimToken } from "../../utils/getSCIMToken";
6
+ import { maskPII, PII_FIELDS } from "../../utils/piiMasking";
7
 
8
  export const schema = {
9
  filter: z
 
24
  .describe(
25
  "Non-negative integer. Specifies the desired maximum number of query results per page."
26
  ),
27
+ piiMasking: z
28
+ .boolean()
29
+ .optional()
30
+ .default(true)
31
+ .describe(
32
+ "Enable PII masking for sensitive fields including member information (username, emails, phone numbers, display names). When true, values are partially masked while maintaining readability. Default: true"
33
+ ),
34
  };
35
 
36
  export const metadata: ResourceMetadata = {
 
43
  filter,
44
  startIndex,
45
  count,
46
+ piiMasking = true,
47
  }: InferSchema<typeof schema>) {
48
  const requestHeaders = headers();
49
  const apiToken = getScimToken(requestHeaders);
 
80
  throw new Error(await response.text());
81
  }
82
 
83
+ let data = await response.json();
84
+
85
+ // Apply PII masking if enabled
86
+ if (piiMasking) {
87
+ data = maskPII(data, PII_FIELDS);
88
+ }
89
 
90
  return {
91
  contents: [
src/resources/(users)/[userId]/index.ts CHANGED
@@ -3,6 +3,7 @@ import { headers } from "xmcp/headers";
3
  import { z } from "zod";
4
  import { getScimBaseUrl } from "../../../utils/getSCIMBaseUrl";
5
  import { getScimToken } from "../../../utils/getSCIMToken";
 
6
 
7
  export const schema = {
8
  userId: z.string().describe("The ID of the user"),
@@ -18,6 +19,13 @@ export const schema = {
18
  .describe(
19
  "Comma-separated list of attribute names to exclude from the response. Per RFC 7644 Section 3.9"
20
  ),
 
 
 
 
 
 
 
21
  };
22
 
23
  export const metadata: ResourceMetadata = {
@@ -30,6 +38,7 @@ export default async function handler({
30
  userId,
31
  attributes,
32
  excludedAttributes,
 
33
  }: InferSchema<typeof schema>) {
34
  const requestHeaders = headers();
35
  const apiToken = getScimToken(requestHeaders);
@@ -65,7 +74,12 @@ export default async function handler({
65
  throw new Error(await response.text());
66
  }
67
 
68
- const data = await response.json();
 
 
 
 
 
69
 
70
  return {
71
  contents: [
 
3
  import { z } from "zod";
4
  import { getScimBaseUrl } from "../../../utils/getSCIMBaseUrl";
5
  import { getScimToken } from "../../../utils/getSCIMToken";
6
+ import { maskPII, PII_FIELDS } from "../../../utils/piiMasking";
7
 
8
  export const schema = {
9
  userId: z.string().describe("The ID of the user"),
 
19
  .describe(
20
  "Comma-separated list of attribute names to exclude from the response. Per RFC 7644 Section 3.9"
21
  ),
22
+ piiMasking: z
23
+ .boolean()
24
+ .optional()
25
+ .default(true)
26
+ .describe(
27
+ "Enable PII masking for sensitive fields (username, emails, phone numbers, addresses). When true, values are partially masked while maintaining readability. Default: true"
28
+ ),
29
  };
30
 
31
  export const metadata: ResourceMetadata = {
 
38
  userId,
39
  attributes,
40
  excludedAttributes,
41
+ piiMasking = true,
42
  }: InferSchema<typeof schema>) {
43
  const requestHeaders = headers();
44
  const apiToken = getScimToken(requestHeaders);
 
74
  throw new Error(await response.text());
75
  }
76
 
77
+ let data = await response.json();
78
+
79
+ // Apply PII masking if enabled
80
+ if (piiMasking) {
81
+ data = maskPII(data, PII_FIELDS);
82
+ }
83
 
84
  return {
85
  contents: [
src/resources/(users)/index.ts CHANGED
@@ -3,6 +3,7 @@ import { headers } from "xmcp/headers";
3
  import { z } from "zod";
4
  import { getScimBaseUrl } from "../../utils/getSCIMBaseUrl";
5
  import { getScimToken } from "../../utils/getSCIMToken";
 
6
 
7
  export const schema = {
8
  filter: z
@@ -23,6 +24,13 @@ export const schema = {
23
  .describe(
24
  "Non-negative integer. Specifies the desired maximum number of query results per page."
25
  ),
 
 
 
 
 
 
 
26
  };
27
 
28
  export const metadata: ResourceMetadata = {
@@ -35,6 +43,7 @@ export default async function handler({
35
  filter,
36
  startIndex,
37
  count,
 
38
  }: InferSchema<typeof schema>) {
39
  const requestHeaders = headers();
40
  const apiToken = getScimToken(requestHeaders);
@@ -71,7 +80,12 @@ export default async function handler({
71
  throw new Error(await response.text());
72
  }
73
 
74
- const data = await response.json();
 
 
 
 
 
75
 
76
  return {
77
  contents: [
 
3
  import { z } from "zod";
4
  import { getScimBaseUrl } from "../../utils/getSCIMBaseUrl";
5
  import { getScimToken } from "../../utils/getSCIMToken";
6
+ import { maskPII, PII_FIELDS } from "../../utils/piiMasking";
7
 
8
  export const schema = {
9
  filter: z
 
24
  .describe(
25
  "Non-negative integer. Specifies the desired maximum number of query results per page."
26
  ),
27
+ piiMasking: z
28
+ .boolean()
29
+ .optional()
30
+ .default(true)
31
+ .describe(
32
+ "Enable PII masking for sensitive fields (username, emails, phone numbers, addresses). When true, values are partially masked while maintaining readability. Also know as privacy mode. Default: true"
33
+ ),
34
  };
35
 
36
  export const metadata: ResourceMetadata = {
 
43
  filter,
44
  startIndex,
45
  count,
46
+ piiMasking = true,
47
  }: InferSchema<typeof schema>) {
48
  const requestHeaders = headers();
49
  const apiToken = getScimToken(requestHeaders);
 
80
  throw new Error(await response.text());
81
  }
82
 
83
+ let data = await response.json();
84
+
85
+ // Apply PII masking if enabled
86
+ if (piiMasking) {
87
+ data = maskPII(data, PII_FIELDS);
88
+ }
89
 
90
  return {
91
  contents: [
src/utils/piiMasking.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function maskEmail(email: string): string {
2
+ const [local, domain] = email.split("@");
3
+ if (!local || !domain) return email;
4
+
5
+ const visibleChars = Math.min(2, Math.floor(local.length * 0.3));
6
+ const masked = local.substring(0, visibleChars) + "******";
7
+ return `${masked}@${domain}`;
8
+ }
9
+
10
+ export function maskPhoneNumber(phone: string): string {
11
+ // Keep country code and last 2 digits visible
12
+ const digits = phone.replace(/\D/g, "");
13
+ if (digits.length < 4) return "***";
14
+
15
+ const lastTwo = digits.slice(-2);
16
+ const prefix = digits.length > 10 ? digits.substring(0, 2) : "";
17
+ return prefix ? `+${prefix}******${lastTwo}` : `******${lastTwo}`;
18
+ }
19
+
20
+ export function maskString(value: string): string {
21
+ if (value.length <= 2) return "***";
22
+
23
+ const visibleChars = Math.min(2, Math.floor(value.length * 0.3));
24
+ const masked = value.substring(0, visibleChars) + "******";
25
+ return masked;
26
+ }
27
+
28
+ export function maskPII(obj: any, piiFields: Set<string>): any {
29
+ if (obj === null || obj === undefined) return obj;
30
+
31
+ if (Array.isArray(obj)) {
32
+ return obj.map(item => maskPII(item, piiFields));
33
+ }
34
+
35
+ if (typeof obj === "object") {
36
+ const masked: any = {};
37
+
38
+ for (const [key, value] of Object.entries(obj)) {
39
+ const lowerKey = key.toLowerCase();
40
+
41
+ // Check if this field contains PII
42
+ if (piiFields.has(lowerKey)) {
43
+ if (typeof value === "string") {
44
+ if (lowerKey.includes("email") || key === "value" && obj.type === "email") {
45
+ masked[key] = maskEmail(value);
46
+ } else if (lowerKey.includes("phone") || lowerKey.includes("mobile") ||
47
+ key === "value" && (obj.type === "phone" || obj.type === "mobile")) {
48
+ masked[key] = maskPhoneNumber(value);
49
+ } else {
50
+ masked[key] = maskString(value);
51
+ }
52
+ } else if (value !== null && typeof value === "object") {
53
+ // Recursively mask nested objects
54
+ masked[key] = maskPII(value, piiFields);
55
+ } else {
56
+ masked[key] = value;
57
+ }
58
+ } else if (value !== null && typeof value === "object") {
59
+ // Recursively process nested objects even for non-PII fields
60
+ masked[key] = maskPII(value, piiFields);
61
+ } else {
62
+ masked[key] = value;
63
+ }
64
+ }
65
+
66
+ return masked;
67
+ }
68
+
69
+ return obj;
70
+ }
71
+
72
+ export const PII_FIELDS = new Set([
73
+ "username",
74
+ "email",
75
+ "emails",
76
+ "displayname",
77
+ "phonenumber",
78
+ "phonenumbers",
79
+ "phone",
80
+ "mobile",
81
+ "streetaddress",
82
+ "locality",
83
+ "region",
84
+ "postalcode",
85
+ "address",
86
+ "addresses",
87
+ "givenname",
88
+ "familyname",
89
+ "middlename",
90
+ "formatted", // formatted name/address
91
+ ]);