diff --git a/packages/core/__tests__/decision.test.ts b/packages/core/__tests__/decision.test.ts index b15a05f..0510e55 100644 --- a/packages/core/__tests__/decision.test.ts +++ b/packages/core/__tests__/decision.test.ts @@ -507,6 +507,113 @@ describe("Configuration errors", () => { await expect(auth.can("x")).rejects.toThrow(/contract violation/i); }); + + it("throws when a source returns a fact with empty string scope", async () => { + const mizan = createMizan(); + mizan.registerSource("bad", { + async resolve() { + return { status: "facts", facts: [{ permission: "x", effect: "grant", scope: "" }] }; + }, + }); + const auth = mizan.forPrincipal("user-1"); + + await expect(auth.can("x")).rejects.toThrow(/contract violation/i); + }); + + it("throws when a source returns a fact with invalid startsAt", async () => { + const mizan = createMizan(); + mizan.registerSource("bad", { + async resolve() { + return { status: "facts", facts: [{ permission: "x", effect: "grant", startsAt: "not-a-date" }] }; + }, + }); + const auth = mizan.forPrincipal("user-1"); + + await expect(auth.can("x")).rejects.toThrow(/contract violation/i); + }); + + it("throws when a source returns a fact with invalid expiresAt", async () => { + const mizan = createMizan(); + mizan.registerSource("bad", { + async resolve() { + return { status: "facts", facts: [{ permission: "x", effect: "grant", expiresAt: "bad-date" }] }; + }, + }); + const auth = mizan.forPrincipal("user-1"); + + await expect(auth.can("x")).rejects.toThrow(/contract violation/i); + }); + + it("throws when a source returns a fact with JavaScript-parseable but non-ISO startsAt", async () => { + const mizan = createMizan(); + mizan.registerSource("bad", { + async resolve() { + return { status: "facts", facts: [{ permission: "x", effect: "grant", startsAt: "January 1, 2026" }] }; + }, + }); + const auth = mizan.forPrincipal("user-1"); + + await expect(auth.can("x")).rejects.toThrow(/contract violation/i); + }); + + it("throws when a source returns a fact with American-format date as startsAt", async () => { + const mizan = createMizan(); + mizan.registerSource("bad", { + async resolve() { + return { status: "facts", facts: [{ permission: "x", effect: "grant", startsAt: "12/25/2024" }] }; + }, + }); + const auth = mizan.forPrincipal("user-1"); + + await expect(auth.can("x")).rejects.toThrow(/contract violation/i); + }); + + it("throws when a source returns a fact with logically invalid ISO date (Feb 30)", async () => { + const mizan = createMizan(); + mizan.registerSource("bad", { + async resolve() { + return { status: "facts", facts: [{ permission: "x", effect: "grant", startsAt: "2024-02-30T00:00:00Z" }] }; + }, + }); + const auth = mizan.forPrincipal("user-1"); + + await expect(auth.can("x")).rejects.toThrow(/contract violation/i); + }); + + it("accepts valid leap year date (Feb 29, 2024)", async () => { + const mizan = createMizan(); + mizan.registerSource("mem", sourceWith({ permission: "x", effect: "grant", startsAt: "2024-02-29T00:00:00Z" })); + const auth = mizan.forPrincipal("user-1"); + + // Should not throw — Feb 29, 2024 is a valid leap year date + await expect(auth.can("x")).resolves.toBe(true); + }); + + it("rejects Feb 29 in non-leap year (2023)", async () => { + const mizan = createMizan(); + mizan.registerSource("bad", { + async resolve() { + return { status: "facts", facts: [{ permission: "x", effect: "grant", startsAt: "2023-02-29T00:00:00Z" }] }; + }, + }); + const auth = mizan.forPrincipal("user-1"); + + await expect(auth.can("x")).rejects.toThrow(/contract violation/i); + }); + + it("zero-length interval (startsAt === expiresAt) is never active", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ permission: "files.read", effect: "grant", startsAt: "2024-06-15T00:00:00Z", expiresAt: "2024-06-15T00:00:00Z" }), + ); + const auth = mizan.forPrincipal("user-1"); + + // At exactly the same instant, the half-open interval [start, end) is empty + const result = await auth.decide("files.read", { at: new Date("2024-06-15T00:00:00Z") }); + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("expired"); + }); }); // ─── Principal binding ───────────────────────────────────────────────────── @@ -519,6 +626,686 @@ describe("forPrincipal()", () => { }); }); +// ─── Scope matching ──────────────────────────────────────────────────────── + +describe("Scope matching", () => { + it("global fact (no scope) matches any requested scope", async () => { + const mizan = createMizan(); + mizan.registerSource("mem", sourceWith({ permission: "files.read", effect: "grant" })); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read", { scope: "tenant-a" })).toBe(true); + expect(await auth.can("files.read", { scope: "tenant-b" })).toBe(true); + }); + + it("scoped fact matches only the corresponding requested scope", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ permission: "files.read", effect: "grant", scope: "tenant-a" }), + ); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read", { scope: "tenant-a" })).toBe(true); + expect(await auth.can("files.read", { scope: "tenant-b" })).toBe(false); + }); + + it("scoped fact does not match when scope is omitted in request", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ permission: "files.read", effect: "grant", scope: "tenant-a" }), + ); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read")).toBe(false); + }); + + it("omitting requested scope means only global facts apply", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith( + { permission: "files.read", effect: "grant" }, + { permission: "files.write", effect: "grant", scope: "tenant-a" }, + ), + ); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read")).toBe(true); + expect(await auth.can("files.write")).toBe(false); + }); + + it("scoped deny overrides scoped grant within same scope", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith( + { permission: "files.delete", effect: "grant", scope: "tenant-a" }, + { permission: "files.delete", effect: "deny", scope: "tenant-a" }, + ), + ); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.delete", { scope: "tenant-a" })).toBe(false); + }); + + it("global (unscoped) denial overrides scoped grant", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith( + { permission: "files.read", effect: "grant", scope: "tenant-a" }, + { permission: "files.read", effect: "deny" }, + ), + ); + const auth = mizan.forPrincipal("user-1"); + + // Global denial matches any scope, overriding the scoped grant + expect(await auth.can("files.read", { scope: "tenant-a" })).toBe(false); + expect(await auth.can("files.read", { scope: "tenant-b" })).toBe(false); + }); + + it("deny in one scope does not affect another scope", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith( + { permission: "files.read", effect: "grant", scope: "tenant-a" }, + { permission: "files.read", effect: "deny", scope: "tenant-b" }, + ), + ); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read", { scope: "tenant-a" })).toBe(true); + expect(await auth.can("files.read", { scope: "tenant-b" })).toBe(false); + }); + + it("decide returns out-of-scope reason when scoped fact doesn't match", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ permission: "files.read", effect: "grant", scope: "tenant-a" }), + ); + const auth = mizan.forPrincipal("user-1"); + + const result = await auth.decide("files.read", { scope: "tenant-b" }); + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("out-of-scope"); + }); +}); + +// ─── Temporal matching ───────────────────────────────────────────────────── + +describe("Temporal matching", () => { + it("fact with no startsAt or expiresAt is always active", async () => { + const mizan = createMizan(); + mizan.registerSource("mem", sourceWith({ permission: "files.read", effect: "grant" })); + const auth = mizan.forPrincipal("user-1"); + + const past = new Date("2020-01-01T00:00:00Z"); + const future = new Date("2099-01-01T00:00:00Z"); + expect(await auth.can("files.read", { at: past })).toBe(true); + expect(await auth.can("files.read", { at: future })).toBe(true); + }); + + it("fact with startsAt in the past is active", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ permission: "files.read", effect: "grant", startsAt: "2024-01-01T00:00:00Z" }), + ); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read", { at: new Date("2024-06-15T00:00:00Z") })).toBe(true); + }); + + it("fact with startsAt in the future is not-yet-active", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ permission: "files.read", effect: "grant", startsAt: "2025-01-01T00:00:00Z" }), + ); + const auth = mizan.forPrincipal("user-1"); + + const result = await auth.decide("files.read", { at: new Date("2024-06-15T00:00:00Z") }); + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("not-yet-active"); + }); + + it("fact with expiresAt in the past is expired", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ permission: "files.read", effect: "grant", expiresAt: "2024-01-01T00:00:00Z" }), + ); + const auth = mizan.forPrincipal("user-1"); + + const result = await auth.decide("files.read", { at: new Date("2024-06-15T00:00:00Z") }); + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("expired"); + }); + + it("half-open interval: startsAt is inclusive, expiresAt is exclusive", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ permission: "files.read", effect: "grant", startsAt: "2024-01-01T00:00:00Z", expiresAt: "2024-12-31T23:59:59Z" }), + ); + const auth = mizan.forPrincipal("user-1"); + + // Exactly at startsAt → active (inclusive) + expect(await auth.can("files.read", { at: new Date("2024-01-01T00:00:00Z") })).toBe(true); + // Exactly at expiresAt → inactive (exclusive) + expect(await auth.can("files.read", { at: new Date("2024-12-31T23:59:59Z") })).toBe(false); + // One ms before expiresAt → active + expect(await auth.can("files.read", { at: new Date("2024-12-31T23:59:58Z") })).toBe(true); + }); + + it("fact with both startsAt and expiresAt within active window", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ permission: "files.read", effect: "grant", startsAt: "2024-01-01T00:00:00Z", expiresAt: "2024-12-31T23:59:59Z" }), + ); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read", { at: new Date("2024-06-15T12:00:00Z") })).toBe(true); + }); + + it("expired denial does not deny (treated as inactive)", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith( + { permission: "files.read", effect: "grant" }, + { permission: "files.read", effect: "deny", expiresAt: "2024-01-01T00:00:00Z" }, + ), + ); + const auth = mizan.forPrincipal("user-1"); + + // Denial is expired, grant is still active → allow + expect(await auth.can("files.read", { at: new Date("2024-06-15T00:00:00Z") })).toBe(true); + }); + + it("not-yet-active denial does not deny (treated as inactive)", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith( + { permission: "files.read", effect: "grant" }, + { permission: "files.read", effect: "deny", startsAt: "2025-01-01T00:00:00Z" }, + ), + ); + const auth = mizan.forPrincipal("user-1"); + + // Denial is not yet active, grant is still active → allow + expect(await auth.can("files.read", { at: new Date("2024-06-15T00:00:00Z") })).toBe(true); + }); + + it("expired grant with no active alternative → deny expired", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ permission: "files.read", effect: "grant", expiresAt: "2024-01-01T00:00:00Z" }), + ); + const auth = mizan.forPrincipal("user-1"); + + const result = await auth.decide("files.read", { at: new Date("2024-06-15T00:00:00Z") }); + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("expired"); + }); +}); + +// ─── Schedule matching ───────────────────────────────────────────────────── + +describe("Schedule matching", () => { + it("fact with no schedule is always active (no schedule constraint)", async () => { + const mizan = createMizan(); + mizan.registerSource("mem", sourceWith({ permission: "files.read", effect: "grant" })); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read")).toBe(true); + }); + + it("weekly window matches during window hours", async () => { + const mizan = createMizan(); + // Wednesday 2024-06-19 at 14:00 UTC is within 09:00-17:00 UTC on Wednesday + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "UTC", + weeks: [{ day: "wednesday", times: [{ start: "09:00", end: "17:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read", { at: new Date("2024-06-19T14:00:00Z") })).toBe(true); + }); + + it("weekly window does not match outside window hours", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "UTC", + weeks: [{ day: "wednesday", times: [{ start: "09:00", end: "17:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + // At 18:00 UTC on Wednesday - outside 09:00-17:00 + const result = await auth.decide("files.read", { at: new Date("2024-06-19T18:00:00Z") }); + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("outside-schedule"); + }); + + it("weekly window does not match on a different day", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "UTC", + weeks: [{ day: "monday", times: [{ start: "09:00", end: "17:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + // Wednesday 2024-06-19 at 14:00 UTC - not Monday + const result = await auth.decide("files.read", { at: new Date("2024-06-19T14:00:00Z") }); + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("outside-schedule"); + }); + + it("multiple time windows per day (OR logic)", async () => { + const mizan = createMizan(); + // Wednesday, two windows: 09:00-12:00 OR 14:00-17:00 + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "UTC", + weeks: [{ day: "wednesday", times: [{ start: "09:00", end: "12:00" }, { start: "14:00", end: "17:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + // 10:00 is in first window + expect(await auth.can("files.read", { at: new Date("2024-06-19T10:00:00Z") })).toBe(true); + // 15:00 is in second window + expect(await auth.can("files.read", { at: new Date("2024-06-19T15:00:00Z") })).toBe(true); + // 13:00 is between windows — outside both + expect(await auth.can("files.read", { at: new Date("2024-06-19T13:00:00Z") })).toBe(false); + }); + + it("decide returns outside-schedule reason for schedule mismatch", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "UTC", + weeks: [{ day: "wednesday", times: [{ start: "09:00", end: "12:00" }, { start: "14:00", end: "17:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + const result = await auth.decide("files.read", { at: new Date("2024-06-19T13:00:00Z") }); + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("outside-schedule"); + }); + + it("date-specific window crosses midnight into next day (overnight)", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "UTC", + dates: [{ date: "2024-12-31", times: [{ start: "22:00", end: "02:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + // Dec 31 at 23:00 UTC — within window + expect(await auth.can("files.read", { at: new Date("2024-12-31T23:00:00Z") })).toBe(true); + // Jan 1 at 01:00 UTC — next day, still within overnight window + expect(await auth.can("files.read", { at: new Date("2025-01-01T01:00:00Z") })).toBe(true); + // Jan 1 at 03:00 UTC — outside window + expect(await auth.can("files.read", { at: new Date("2025-01-01T03:00:00Z") })).toBe(false); + }); + + it("fractional timezone offset (Asia/Kolkata UTC+5:30) works", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "Asia/Kolkata", + weeks: [{ day: "wednesday", times: [{ start: "09:00", end: "17:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + // 2024-06-19 03:30 UTC = 09:00 IST — at window start (inclusive) + expect(await auth.can("files.read", { at: new Date("2024-06-19T03:30:00Z") })).toBe(true); + // 2024-06-19 04:00 UTC = 09:30 IST — within window + expect(await auth.can("files.read", { at: new Date("2024-06-19T04:00:00Z") })).toBe(true); + // 2024-06-19 11:29 UTC = 16:59 IST — within window + expect(await auth.can("files.read", { at: new Date("2024-06-19T11:29:00Z") })).toBe(true); + // 2024-06-19 11:31 UTC = 17:01 IST — after window end (exclusive) + expect(await auth.can("files.read", { at: new Date("2024-06-19T11:31:00Z") })).toBe(false); + }); + + it("leap year date window (Feb 29) works", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "UTC", + dates: [{ date: "2024-02-29", times: [{ start: "09:00", end: "17:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + // Leap day, during window + expect(await auth.can("files.read", { at: new Date("2024-02-29T12:00:00Z") })).toBe(true); + // Leap day, outside window + expect(await auth.can("files.read", { at: new Date("2024-02-29T20:00:00Z") })).toBe(false); + // Next day (March 1) — no longer active + expect(await auth.can("files.read", { at: new Date("2024-03-01T12:00:00Z") })).toBe(false); + }); + + it("date-specific window on matching date", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "UTC", + dates: [{ date: "2024-12-25", times: [{ start: "09:00", end: "17:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + expect(await auth.can("files.read", { at: new Date("2024-12-25T10:00:00Z") })).toBe(true); + expect(await auth.can("files.read", { at: new Date("2024-12-26T10:00:00Z") })).toBe(false); + }); + + it("overnight window (start > end) works correctly", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "UTC", + weeks: [{ day: "wednesday", times: [{ start: "22:00", end: "02:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + // Wednesday 23:00 UTC — within window (22:00 Wed -> 02:00 Thu) + expect(await auth.can("files.read", { at: new Date("2024-06-19T23:00:00Z") })).toBe(true); + // Thursday 01:00 UTC — still within window (Wednesday overnight) + expect(await auth.can("files.read", { at: new Date("2024-06-20T01:00:00Z") })).toBe(true); + // Thursday 03:00 UTC — outside window + expect(await auth.can("files.read", { at: new Date("2024-06-20T03:00:00Z") })).toBe(false); + // Wednesday 21:00 UTC — before window opens + expect(await auth.can("files.read", { at: new Date("2024-06-19T21:00:00Z") })).toBe(false); + // Wednesday 01:00 UTC — early morning of listed day, should NOT match + // (the window is 22:00 Wed → 02:00 Thu, so Wed 01:00 is before 22:00 Wed) + expect(await auth.can("files.read", { at: new Date("2024-06-19T01:00:00Z") })).toBe(false); + }); + + it("overnight window early morning of listed day denies (not inside window yet)", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "UTC", + weeks: [{ day: "wednesday", times: [{ start: "22:00", end: "02:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + // Wednesday 01:00 — early morning BEFORE the 22:00-02:00 overnight window opens + const result = await auth.decide("files.read", { at: new Date("2024-06-19T01:00:00Z") }); + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("outside-schedule"); + }); + + it("overnight window at midnight of listed day is within evening portion", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "UTC", + weeks: [{ day: "wednesday", times: [{ start: "22:00", end: "02:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + // Wednesday 23:30 — evening portion of overnight window + expect(await auth.can("files.read", { at: new Date("2024-06-19T23:30:00Z") })).toBe(true); + }); + + it("timezone conversion: local time zone affects matching", async () => { + const mizan = createMizan(); + // Window is 09:00-17:00 in America/New_York (UTC-4 in June) + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "America/New_York", + weeks: [{ day: "wednesday", times: [{ start: "09:00", end: "17:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + // 2024-06-19 12:00 UTC = 08:00 ET (EDT, UTC-4) → before 09:00 ET + expect(await auth.can("files.read", { at: new Date("2024-06-19T12:00:00Z") })).toBe(false); + // 2024-06-19 14:00 UTC = 10:00 ET → within 09:00-17:00 ET + expect(await auth.can("files.read", { at: new Date("2024-06-19T14:00:00Z") })).toBe(true); + // 2024-06-19 22:00 UTC = 18:00 ET → after 17:00 ET + expect(await auth.can("files.read", { at: new Date("2024-06-19T22:00:00Z") })).toBe(false); + }); + + it("fact must satisfy both temporal window AND schedule", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + startsAt: "2024-06-01T00:00:00Z", + expiresAt: "2024-06-30T23:59:59Z", + schedule: { + timezone: "UTC", + weeks: [{ day: "monday", times: [{ start: "09:00", end: "17:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + // Monday 2024-06-17 14:00 UTC — within temporal window AND within schedule + expect(await auth.can("files.read", { at: new Date("2024-06-17T14:00:00Z") })).toBe(true); + // Wednesday 2024-06-19 14:00 UTC — within temporal window BUT Wednesday, not Monday + const scheduleResult = await auth.decide("files.read", { at: new Date("2024-06-19T14:00:00Z") }); + expect(scheduleResult.decision).toBe("deny"); + expect(scheduleResult.reason).toBe("outside-schedule"); + // Monday 2024-05-20 14:00 UTC — within schedule (Monday) BUT outside temporal window (before June) + const temporalResult = await auth.decide("files.read", { at: new Date("2024-05-20T14:00:00Z") }); + expect(temporalResult.decision).toBe("deny"); + expect(temporalResult.reason).toBe("not-yet-active"); + }); + + it("empty schedule (no weeks, no dates arrays) means no active time", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "UTC", + weeks: [], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + const result = await auth.decide("files.read", { at: new Date("2024-06-19T14:00:00Z") }); + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("outside-schedule"); + }); + + it("outside-schedule takes priority over not-yet-active when both exist", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith( + { + permission: "files.read", + effect: "grant", + schedule: { + timezone: "UTC", + weeks: [{ day: "monday", times: [{ start: "09:00", end: "17:00" }] }], + }, + }, + { + permission: "files.read", + effect: "grant", + startsAt: "2025-01-01T00:00:00Z", + }, + ), + ); + const auth = mizan.forPrincipal("user-1"); + + // Wednesday outside schedule + also not yet active — schedule priority wins + const result = await auth.decide("files.read", { at: new Date("2024-06-19T14:00:00Z") }); + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("outside-schedule"); + }); + + it("DST spring-forward: overnight window spanning transition (known limitation)", async () => { + const mizan = createMizan(); + // Overnight window 22:00-02:00 on Sunday in America/New_York + // Spring-forward 2024-03-10: clocks jump from 02:00 to 03:00 at 02:00 EST + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "America/New_York", + weeks: [{ day: "sunday", times: [{ start: "22:00", end: "02:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + // Sunday 2024-03-10 22:00 EDT (02:00 UTC Mar 11) — well after spring-forward, normal evening + // 2024-03-11 02:00 UTC = 2024-03-10 22:00 EDT — within evening portion + expect(await auth.can("files.read", { at: new Date("2024-03-11T02:00:00Z") })).toBe(true); + // This test documents current behavior. See issue #45 for DST correctness. + }); + + it("DST fall-back: overnight window spanning transition (known limitation)", async () => { + const mizan = createMizan(); + // Overnight window 22:00-02:00 on Sunday in America/New_York + // Fall-back 2024-11-03: clocks fall back from 02:00 EDT to 01:00 EST at 02:00 EDT + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + schedule: { + timezone: "America/New_York", + weeks: [{ day: "sunday", times: [{ start: "22:00", end: "02:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + // Sunday 2024-11-03 22:00 EST (03:00 UTC Nov 4) — evening portion + expect(await auth.can("files.read", { at: new Date("2024-11-04T03:00:00Z") })).toBe(true); + // This test documents current behavior. See issue #45 for DST correctness. + }); + + it("scope, temporal, and schedule all combined (triple-dimension)", async () => { + const mizan = createMizan(); + mizan.registerSource( + "mem", + sourceWith({ + permission: "files.read", + effect: "grant", + scope: "tenant-a", + startsAt: "2024-06-01T00:00:00Z", + expiresAt: "2024-06-30T23:59:59Z", + schedule: { + timezone: "UTC", + weeks: [{ day: "monday", times: [{ start: "09:00", end: "17:00" }] }], + }, + }), + ); + const auth = mizan.forPrincipal("user-1"); + + // All three match: correct scope, within temporal window, Monday during hours + expect(await auth.can("files.read", { scope: "tenant-a", at: new Date("2024-06-17T14:00:00Z") })).toBe(true); + // Scope mismatch + expect(await auth.can("files.read", { scope: "tenant-b", at: new Date("2024-06-17T14:00:00Z") })).toBe(false); + // Temporal mismatch (before startsAt) + expect(await auth.can("files.read", { scope: "tenant-a", at: new Date("2024-05-20T14:00:00Z") })).toBe(false); + // Schedule mismatch (Wednesday, not Monday) + expect(await auth.can("files.read", { scope: "tenant-a", at: new Date("2024-06-19T14:00:00Z") })).toBe(false); + }); +}); + // ─── Mizan class API ─────────────────────────────────────────────────────── describe("Mizan", () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 17773fc..a88a460 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -71,6 +71,8 @@ export type DenyReason = | "no-grant" | "matching-denial" | "expired" + | "not-yet-active" + | "outside-schedule" | "out-of-scope" | "guard-denied" | "source-unavailable" @@ -123,15 +125,30 @@ export interface AuthorizationFact { /** * A recurring time window, evaluated against the current time in the * specified IANA time zone. + * + * At least one of `weeks` or `dates` must be provided. An empty array + * for both means no active time windows. */ -export interface RecurringSchedule { +export type RecurringSchedule = { /** IANA time zone identifier (e.g., "Europe/Berlin", "America/New_York"). */ readonly timezone: string; /** Weekly windows (day-of-week + time ranges). */ - readonly weeks?: WeeklyWindow[]; + readonly weeks: WeeklyWindow[]; /** Date-specific windows (calendar dates + time ranges). */ readonly dates?: DateWindow[]; -} +} | { + /** IANA time zone identifier (e.g., "Europe/Berlin", "America/New_York"). */ + readonly timezone: string; + /** Weekly windows (day-of-week + time ranges). */ + readonly weeks?: WeeklyWindow[]; + /** Date-specific windows (calendar dates + time ranges). */ + readonly dates: DateWindow[]; +}; + +/** Ordered list of days for computing next-day overnight windows. */ +const DAYS: DayOfWeek[] = [ + "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", +]; export type DayOfWeek = | "monday" @@ -223,6 +240,20 @@ export interface SourcePlanEntry { readonly onUnavailable?: UnavailablePolicy; } +/** + * Optional parameters for `can()` and `decide()`. + */ +export interface EvaluateOptions { + /** + * Requested scope. Omitted means only unscoped (global) facts apply. + */ + readonly scope?: string; + /** + * Evaluation timestamp. Defaults to current time when omitted. + */ + readonly at?: Date; +} + export type CompositionStrategy = "fallback" | "merge" | "authoritative"; export interface SourcePlan { @@ -338,6 +369,7 @@ async function collectFacts( plans: Map, principalId: string, planName?: string, + at?: Date, ): Promise { let targetSources: Map; @@ -367,7 +399,7 @@ async function collectFacts( targetSources = new Map(sources); } - const now = new Date(); + const now = at ?? new Date(); const allFacts: AuthorizationFact[] = []; for (const [name, resolver] of targetSources) { @@ -421,6 +453,27 @@ async function collectFacts( `Contract violation: source "${name}" returned a fact with an unsupported effect "${fact.effect}"`, ); } + if (fact.scope === null) { + throw new TypeError( + `Contract violation: source "${name}" returned a fact with a null scope. Use undefined for global applicability, or provide a non-empty scope string.`, + ); + } + if (fact.scope !== undefined && fact.scope.length === 0) { + throw new TypeError( + `Contract violation: source "${name}" returned a fact with an empty string scope. Use undefined for global applicability, or provide a non-empty scope string.`, + ); + } + // Validate startsAt/expiresAt are strict ISO 8601 timestamps. + if (fact.startsAt !== undefined && !isStrictISODate(fact.startsAt)) { + throw new TypeError( + `Contract violation: source "${name}" returned a fact with an invalid startsAt "${fact.startsAt}". Expected ISO 8601 format (e.g., "2024-01-01T00:00:00Z").`, + ); + } + if (fact.expiresAt !== undefined && !isStrictISODate(fact.expiresAt)) { + throw new TypeError( + `Contract violation: source "${name}" returned a fact with an invalid expiresAt "${fact.expiresAt}". Expected ISO 8601 format (e.g., "2024-12-31T23:59:59Z").`, + ); + } allFacts.push(fact); } } @@ -429,31 +482,435 @@ async function collectFacts( return allFacts; } +/** + * Check whether a fact matches the requested scope. + * + * - If the fact has no scope, it is global and matches any request. + * - If the fact has a scope, it matches only when the request has the same scope. + */ +function isInScope(fact: AuthorizationFact, requestedScope?: string): boolean { + if (fact.scope === undefined || fact.scope === null) { + // Global fact — matches any scope request. + return true; + } + // Scoped fact — matches only when the request asks for the same scope. + return fact.scope === requestedScope; +} + +/** + * Strict ISO 8601 timestamp validator. + * + * Accepts only full-date or full-date + full-time formats with optional + * timezone ("Z" or ±HH:MM). Rejects JavaScript-parseable but non-ISO + * inputs such as "January 1, 2026" or "12/25/2024". + * + * Pattern: YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss[.sss][Z|±HH:MM] + */ + +/** + * Regex matching strict ISO 8601 date/datetime formats: + * YYYY-MM-DD + * YYYY-MM-DDTHH:mm:ss + * YYYY-MM-DDTHH:mm:ss.sss + * With optional Z or ±HH:MM timezone + */ +const ISO_DATE_RE = + /^(\d{4})-(\d{2})-(\d{2})(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/; + +/** Days in each month for non-leap years. */ +const DAYS_IN_MONTH = [ + 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, +] as const; + +function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +/** + * Validate that a date string is strict ISO 8601 AND logically valid. + * + * Rejects: + * - Non-ISO formats like "January 1, 2026" or "12/25/2024" + * - Logically invalid dates like "2024-02-30" or "2024-13-01" + * - Uses regex for format + manual component checking — no new Date() parsing. + */ +function isStrictISODate(value: string): boolean { + const match = ISO_DATE_RE.exec(value); + if (!match) { + return false; + } + const year = Number.parseInt(match[1]!, 10); + const month = Number.parseInt(match[2]!, 10); + const day = Number.parseInt(match[3]!, 10); + + // Month must be 01-12 + if (month < 1 || month > 12) { + return false; + } + // Day must be >= 1 + if (day < 1) { + return false; + } + // Day must not exceed month's max days (accounting for leap year) + const maxDay = DAYS_IN_MONTH[month - 1]! + (month === 2 && isLeapYear(year) ? 1 : 0); + if (day > maxDay) { + return false; + } + return true; +} + +/** + * Result of checking a fact's temporal and schedule activity. + */ +type FactActivity = + | { readonly active: true } + | { readonly active: false; readonly reason: "expired" | "not-yet-active" }; + +/** + * Parse a "HH:mm" string into total minutes from midnight. + */ +function timeToMinutes(t: string): number { + const parts = t.split(":").map(Number); + return (parts[0] ?? 0) * 60 + (parts[1] ?? 0); +} + +/** + * Check whether a time range with `start > end` is an overnight window + * (spans midnight into the next day). + * + * Uses `>` not `>=` so that equal start/end (e.g., "09:00"–"09:00") is + * treated as a normal zero-length window, not an overnight wrap. + */ +function isOvernightRange(start: string, end: string): boolean { + return timeToMinutes(start) > timeToMinutes(end); +} + +/** + * Check whether the given time-of-day (in minutes from midnight) falls + * within a time range that belongs to the current day. + * + * - Normal (start <= end): start <= time < end + * - Overnight (start > end): only the evening portion (time >= start) + * belongs to the listed day; the early-morning portion (time < end) + * is handled by {@link isOvernightNextActive} on the next day. + */ +function isTimeInRangeSameDay(timeMinutes: number, start: string, end: string): boolean { + const s = timeToMinutes(start); + const e = timeToMinutes(end); + if (s <= e) { + return timeMinutes >= s && timeMinutes < e; + } + // Overnight: only the evening portion (>= start) belongs to the listed day. + return timeMinutes >= s && timeMinutes < 1440; +} + +/** + * Full-range check (including overnight wrap) used when the current day + * is the next day after the listed day. This is only used internally by + * {@link isOvernightNextActive}. + */ +function isTimeInRangeNextDay(timeMinutes: number, end: string): boolean { + return timeMinutes < timeToMinutes(end); +} + +/** + * Simple cache for Intl.DateTimeFormat instances keyed by locale+options. + * Avoids re-creating formatters per-fact during evaluation. + * Capped at 100 entries to prevent memory growth in long-running processes. + */ +const dateTimeFormatCache = new Map(); +const CACHE_MAX_SIZE = 100; + +function getDateTimeFormat( + locale: string, + options: Intl.DateTimeFormatOptions, +): Intl.DateTimeFormat { + const key = `${locale}\x00${JSON.stringify(options)}`; + let fmt = dateTimeFormatCache.get(key); + if (!fmt) { + if (dateTimeFormatCache.size >= CACHE_MAX_SIZE) { + // Evict oldest entry (first key) to keep cache bounded + const firstKey = dateTimeFormatCache.keys().next().value; + if (firstKey !== undefined) { + dateTimeFormatCache.delete(firstKey); + } + } + fmt = new Intl.DateTimeFormat(locale, options); + dateTimeFormatCache.set(key, fmt); + } + return fmt; +} + +/** + * Get the day-of-week name in the given IANA timezone for a UTC date. + */ +function getWeekdayInTimezone(date: Date, timezone: string): DayOfWeek { + const formatter = getDateTimeFormat("en-US", { + timeZone: timezone, + weekday: "long", + }); + return formatter.format(date).toLowerCase() as DayOfWeek; +} + +/** + * Get the date string in "YYYY-MM-DD" format in the given IANA timezone. + */ +function getDateInTimezone(date: Date, timezone: string): string { + // Use toLocaleDateString with en-CA locale which produces YYYY-MM-DD. + // The cached formatter avoids repeated allocations. + const formatter = getDateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + return formatter.format(date); +} + +/** + * Get the local time in the given IANA timezone as total minutes from midnight. + */ +function getTimeInTimezone(date: Date, timezone: string): number { + const formatter = getDateTimeFormat("en-US", { + timeZone: timezone, + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + return timeToMinutes(formatter.format(date)); +} + +/** + * Return the day after the given day. + */ +function nextDay(day: DayOfWeek): DayOfWeek { + const idx = DAYS.indexOf(day); + return DAYS[(idx + 1) % 7]!; +} + +/** + * Return the date string (YYYY-MM-DD) for the day after the given date. + */ +function nextDate(dateStr: string): string { + const d = new Date(dateStr + "T00:00:00Z"); + d.setUTCDate(d.getUTCDate() + 1); + return d.toISOString().slice(0, 10); +} + +/** + * Check whether the current time (in minutes from midnight) falls within + * the overnight extension of any of the given time ranges. + * + * An overnight window (start > end) on a given day also covers the next + * day from midnight until the end time. This helper checks that condition. + */ +function isOvernightNextActive( + timeMinutes: number, + ranges: TimeRange[], +): boolean { + for (const range of ranges) { + if (isOvernightRange(range.start, range.end) && isTimeInRangeNextDay(timeMinutes, range.end)) { + return true; + } + } + return false; +} + +/** + * Check whether a fact's recurring schedule is active at the given + * UTC date, by converting to the schedule's IANA timezone. + * + * - No schedule → always active (no constraint). + * - Empty schedule (no weeks, no dates) → never active. + * - Checks both weekly windows and date-specific windows. + * - Multiple windows are OR'd (any match = active). + * - Overnight windows (start > end) handled correctly. + * + * Known limitation: During DST transitions (spring-forward/fall-back), + * the flat "minutes from midnight" arithmetic may be off by one hour + * for overnight windows that span the transition. This affects only + * a few hours per year and is a known trade-off to avoid introducing + * an external timezone library. + */ +function isScheduleActive( + schedule: RecurringSchedule, + at: Date, +): boolean { + const weekday = getWeekdayInTimezone(at, schedule.timezone); + const dateStr = getDateInTimezone(at, schedule.timezone); + const timeMinutes = getTimeInTimezone(at, schedule.timezone); + + // Check weekly windows + for (const week of schedule.weeks ?? []) { + // Check on the listed day + if (week.day === weekday) { + for (const range of week.times) { + if (isTimeInRangeSameDay(timeMinutes, range.start, range.end)) { + return true; + } + } + } + // Check the next day for overnight windows + const dayAfter = nextDay(week.day); + if (dayAfter === weekday) { + if (isOvernightNextActive(timeMinutes, week.times)) { + return true; + } + } + } + + // Check date windows + for (const dw of schedule.dates ?? []) { + // Check on the listed date + if (dw.date === dateStr) { + for (const range of dw.times) { + if (isTimeInRangeSameDay(timeMinutes, range.start, range.end)) { + return true; + } + } + } + // Check the next date for overnight windows + const dateAfter = nextDate(dw.date); + if (dateAfter === dateStr) { + if (isOvernightNextActive(timeMinutes, dw.times)) { + return true; + } + } + } + + return false; +} + +/** + * Check whether a fact is temporally active at the given time. + * + * Uses a half-open interval where `startsAt` is inclusive and `expiresAt` is exclusive. + * - Missing `startsAt` → active immediately. + * - Missing `expiresAt` → never expires. + */ +function isTemporallyActive( + fact: AuthorizationFact, + at: Date, +): FactActivity { + const time = at.getTime(); + + if (fact.startsAt !== undefined) { + const start = new Date(fact.startsAt).getTime(); + if (Number.isNaN(start)) { + return { active: false, reason: "not-yet-active" }; + } + if (time < start) { + return { active: false, reason: "not-yet-active" }; + } + } + + if (fact.expiresAt !== undefined) { + const end = new Date(fact.expiresAt).getTime(); + if (Number.isNaN(end)) { + return { active: false, reason: "expired" }; + } + if (time >= end) { + return { active: false, reason: "expired" }; + } + } + + return { active: true }; +} + /** * Evaluate all facts against a single permission and return the decision. * - * v0.1 logic: + * Logic: * 1. Filter to pattern-matching facts (exact, global `*`, or namespace `files.*`). - * 2. If any matching denial exists → deny (matching-denial). - * 3. If any matching grant exists → allow. - * 4. Otherwise → deny (no-grant). + * 2. Evaluate each matching fact's scope, temporal, and schedule activity. + * 3. If any active denial exists → deny (matching-denial). + * 4. If any active grant exists → allow. + * 5. If matching facts exist but all are out-of-scope → deny (out-of-scope). + * 6. If matching facts exist but all are temporally inactive → deny (expired or not-yet-active). + * 7. Otherwise → deny (no-grant). */ function evaluate( facts: AuthorizationFact[], permission: string, + options?: { scope?: string; at?: Date }, ): AuthorizationResult { const matching = facts.filter((f) => matchesPermission(permission, f.permission)); + if (matching.length === 0) { + return { decision: "deny", reason: "no-grant" }; + } - const hasDenial = matching.some((f) => f.effect === "deny"); - if (hasDenial) { + const requestedScope = options?.scope; + const at = options?.at ?? new Date(); + + // Separate facts by scope, temporal activity, schedule, and effect. + const activeGrants: AuthorizationFact[] = []; + const activeDenials: AuthorizationFact[] = []; + const expired: AuthorizationFact[] = []; + const notYetActive: AuthorizationFact[] = []; + const outsideSchedule: AuthorizationFact[] = []; + const outOfScope: AuthorizationFact[] = []; + + for (const fact of matching) { + // Scope check first + if (!isInScope(fact, requestedScope)) { + outOfScope.push(fact); + continue; + } + + // Temporal check + const temporal = isTemporallyActive(fact, at); + if (!temporal.active) { + if (temporal.reason === "expired") { + expired.push(fact); + } else { + notYetActive.push(fact); + } + continue; + } + + // Schedule check + if (fact.schedule !== undefined) { + const scheduleActive = isScheduleActive(fact.schedule, at); + if (!scheduleActive) { + outsideSchedule.push(fact); + continue; + } + } + + // Active fact — evaluate effect + if (fact.effect === "deny") { + activeDenials.push(fact); + } else { + activeGrants.push(fact); + } + } + + if (activeDenials.length > 0) { return { decision: "deny", reason: "matching-denial" }; } - const hasGrant = matching.some((f) => f.effect === "grant"); - if (hasGrant) { + if (activeGrants.length > 0) { return { decision: "allow", reason: null }; } + // All matching facts were inactive — pick the most specific reason. + // Priority order: scope > absolute time > schedule > future start. + // This is a deliberate choice: scope is fundamental (you cannot access + // what isn't yours), then absolute expiry (a lapsed permission), then + // schedule mismatch (outside business hours), then future activation. + if (outOfScope.length > 0) { + return { decision: "deny", reason: "out-of-scope" }; + } + if (expired.length > 0) { + return { decision: "deny", reason: "expired" }; + } + if (outsideSchedule.length > 0) { + return { decision: "deny", reason: "outside-schedule" }; + } + if (notYetActive.length > 0) { + return { decision: "deny", reason: "not-yet-active" }; + } + return { decision: "deny", reason: "no-grant" }; } @@ -478,10 +935,12 @@ export class PrincipalEvaluator { /** * Check whether this principal has a permission. * + * @param permission - The permission key to check. + * @param options - Optional scope and evaluation time. * @returns `true` if the permission is granted, `false` otherwise. */ - async can(permission: string): Promise { - const result = await this.decide(permission); + async can(permission: string, options?: EvaluateOptions): Promise { + const result = await this.decide(permission, options); return result.decision === "allow"; } @@ -490,16 +949,20 @@ export class PrincipalEvaluator { * * Unlike `can`, `decide` returns a full `AuthorizationResult` with * a stable reason code, suitable for auditing and diagnostics. + * + * @param permission - The permission key to check. + * @param options - Optional scope and evaluation time. */ - async decide(permission: string): Promise { + async decide(permission: string, options?: EvaluateOptions): Promise { if (this.sources.size === 0) { throw new Error( "No sources registered on the Mizan instance. Register at least one source via registerSource() or useMemoryAdapter() before calling can/decide.", ); } - const facts = await collectFacts(this.sources, this.plans, this.principalId, this.planName); - return evaluate(facts, permission); + const at = options?.at ?? new Date(); + const facts = await collectFacts(this.sources, this.plans, this.principalId, this.planName, at); + return evaluate(facts, permission, { ...options, at }); } }