diff --git a/code-reviews/Security/findings.md b/code-reviews/Security/findings.md index 4b0cf76..997025f 100644 --- a/code-reviews/Security/findings.md +++ b/code-reviews/Security/findings.md @@ -8,7 +8,7 @@ | Last reviewed | 2026-05-16 | | Reviewer | claude-agent | | Commit reviewed | `9c60592` | -| Open findings | 8 | +| Open findings | 4 | ## Summary @@ -156,7 +156,7 @@ corrected to state the requirement. Regression tests |--|--| | Severity | Medium | | Category | Correctness & logic bugs | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.Security/LdapAuthService.cs:66`, `:138`, `:157-159` | **Description** @@ -176,7 +176,15 @@ consistently in both the search filter and the fallback DN. Update the XML doc t **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit `pending`). Confirmed: the search filter was hard-coded +`(uid={username})` (both in `AuthenticateAsync` and `ResolveUserDnAsync`) while the +fallback DN used `cn={username}` — the two auth modes were not interchangeable. Added +a configurable `SecurityOptions.LdapUserIdAttribute` (default `uid`) used for both the +search filter and the fallback DN via the new `BuildFallbackUserDn` helper, and +corrected the `LdapServiceAccountDn` XML doc to reference `{LdapUserIdAttribute}`. +Regression tests `BuildFallbackUserDn_UsesConfiguredUserIdAttribute`, +`BuildFallbackUserDn_HonoursNonDefaultUserIdAttribute`, +`SecurityOptions_LdapUserIdAttribute_DefaultsToUid`. ### Security-005 — DN injection in the no-service-account bind fallback @@ -184,7 +192,7 @@ _Unresolved._ |--|--| | Severity | Medium | | Category | Security | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.Security/LdapAuthService.cs:157-159` | **Description** @@ -207,7 +215,15 @@ raw DN from untrusted input is risky; restrict it or remove it. **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit `pending`). Confirmed: the fallback path interpolated +the raw `username` straight into `cn={username},{LdapSearchBase}` with no DN escaping, +and the `username.Contains('=')` shortcut let a caller supply an arbitrary bind DN. +Added an RFC 4514 `EscapeLdapDn` helper (escapes `, + " \ < > ;`, leading/trailing +space, leading `#`, NUL) applied in `BuildFallbackUserDn`, so a username such as +`victim,ou=admins` can no longer alter the DN structure. The `Contains('=')` raw-DN +shortcut was removed entirely — untrusted input no longer controls the bind identity. +Regression tests `BuildFallbackUserDn_EscapesDnMetacharacters`, +`EscapeLdapDn_EscapesAllRfc4514Specials`, `EscapeLdapDn_EscapesLeadingAndTrailingSpaces`. ### Security-006 — JWT validation disables issuer and audience checks @@ -215,7 +231,7 @@ _Unresolved._ |--|--| | Severity | Medium | | Category | Security | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.Security/JwtTokenService.cs:67-75`, `:56-59` | **Description** @@ -236,7 +252,14 @@ validation. **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit `pending`). Confirmed: `GenerateToken` set neither `iss` +nor `aud` and `ValidateToken` had `ValidateIssuer = false`/`ValidateAudience = false`. +`GenerateToken` now binds `JwtTokenService.TokenIssuer`/`TokenAudience` +(both `"scadalink-central"`) into every token, and `ValidateToken` enables +`ValidateIssuer`/`ValidateAudience` against those fixed values — a token signed with +the shared key but a foreign issuer is now rejected. Regression tests +`GenerateToken_SetsIssuerAndAudience`, `ValidateToken_RejectsTokenWithWrongIssuer`, +`ValidateToken_AcceptsOwnIssuerAndAudience`. ### Security-007 — Idle-timeout claim is reset on every token refresh @@ -244,7 +267,7 @@ _Unresolved._ |--|--| | Severity | Medium | | Category | Correctness & logic bugs | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.Security/JwtTokenService.cs:40`, `:111-123` | **Description** @@ -269,7 +292,22 @@ path agree on the semantics. **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit `pending`). Confirmed: `RefreshToken` → `GenerateToken` +unconditionally wrote `LastActivity = UtcNow`, so the idle clock reset on every +refresh and the documented 30-minute idle timeout could never fire for a client that +polls in the background. Implemented option (a) — the Security-side half of the +documented "15-min sliding + 30-min idle" policy (the cross-module partner of +CentralUI-005): `GenerateToken` now takes an optional `lastActivity` anchor; +`RefreshToken` carries the **existing** `LastActivity` claim forward unchanged, and a +new explicit `RecordActivity` method advances the anchor to now — to be called by the +CentralUI request pipeline on genuine user interaction (not on a background refresh). +`IsIdleTimedOut` is unchanged and now agrees with the refresh path. The remaining +CentralUI-side wiring (calling `RecordActivity` from the middleware, setting +`SlidingExpiration`/`ExpireTimeSpan`) stays tracked under CentralUI-005; this finding's +Security-side defect — the reset-on-refresh bug — is fully fixed here. Regression tests +`RefreshToken_PreservesOriginalLastActivityClaim`, +`RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle`, +`RecordActivity_UpdatesLastActivityToNow`. ### Security-008 — N+1 query loading site-scope rules in `RoleMapper` diff --git a/src/ScadaLink.Security/JwtTokenService.cs b/src/ScadaLink.Security/JwtTokenService.cs index 14747b3..17ea864 100644 --- a/src/ScadaLink.Security/JwtTokenService.cs +++ b/src/ScadaLink.Security/JwtTokenService.cs @@ -18,6 +18,17 @@ public class JwtTokenService public const string SiteIdClaimType = "SiteId"; public const string LastActivityClaimType = "LastActivity"; + /// + /// Fixed issuer bound into every token and required on validation. Binding + /// issuer/audience is defence-in-depth: even though the HMAC key is shared only + /// between the two central nodes, accidental reuse of the same secret for an + /// unrelated internal token would otherwise be silently exploitable. + /// + public const string TokenIssuer = "scadalink-central"; + + /// Fixed audience bound into every token and required on validation. + public const string TokenAudience = "scadalink-central"; + public JwtTokenService(IOptions options, ILogger logger) { _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); @@ -37,11 +48,19 @@ public class JwtTokenService } } + /// + /// Issues a fresh JWT. sets the idle-timeout + /// anchor; when omitted (a brand-new login) it defaults to now. On a token + /// refresh the caller MUST pass the existing anchor forward so the idle window + /// continues to be measured from the user's last genuine activity rather than + /// from token issuance time. + /// public string GenerateToken( string displayName, string username, IReadOnlyList roles, - IReadOnlyList? permittedSiteIds) + IReadOnlyList? permittedSiteIds, + DateTimeOffset? lastActivity = null) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey)); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); @@ -50,7 +69,7 @@ public class JwtTokenService { new(DisplayNameClaimType, displayName), new(UsernameClaimType, username), - new(LastActivityClaimType, DateTimeOffset.UtcNow.ToString("o")) + new(LastActivityClaimType, (lastActivity ?? DateTimeOffset.UtcNow).ToString("o")) }; foreach (var role in roles) @@ -67,6 +86,8 @@ public class JwtTokenService } var token = new JwtSecurityToken( + issuer: TokenIssuer, + audience: TokenAudience, claims: claims, expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes), signingCredentials: credentials); @@ -79,8 +100,10 @@ public class JwtTokenService var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey)); var validationParameters = new TokenValidationParameters { - ValidateIssuer = false, - ValidateAudience = false, + ValidateIssuer = true, + ValidIssuer = TokenIssuer, + ValidateAudience = true, + ValidAudience = TokenAudience, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKey = key, @@ -121,6 +144,15 @@ public class JwtTokenService return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes; } + /// + /// Issues a fresh token (new expiry, re-queried roles) while preserving the + /// existing anchor. A refresh is itself + /// triggered by a request, but it must not be treated as user activity — the + /// idle window must keep being measured from the user's last genuine interaction, + /// otherwise the documented 30-minute idle timeout could never fire for a client + /// that polls in the background. Call to advance the + /// anchor when handling a genuine user request. + /// public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList currentRoles, IReadOnlyList? permittedSiteIds) { var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value; @@ -132,6 +164,36 @@ public class JwtTokenService return null; } - return GenerateToken(displayName, username, currentRoles, permittedSiteIds); + return GenerateToken(displayName, username, currentRoles, permittedSiteIds, + ReadLastActivity(currentPrincipal)); + } + + /// + /// Issues a fresh token whose anchor is + /// advanced to now. This is the explicit "user did something" path — distinct + /// from — to be called by the request pipeline when + /// handling a genuine user interaction. + /// + public string? RecordActivity(ClaimsPrincipal currentPrincipal, IReadOnlyList currentRoles, IReadOnlyList? permittedSiteIds) + { + var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value; + var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value; + + if (displayName == null || username == null) + { + _logger.LogWarning("Cannot record activity: missing DisplayName or Username claims"); + return null; + } + + return GenerateToken(displayName, username, currentRoles, permittedSiteIds, + DateTimeOffset.UtcNow); + } + + private static DateTimeOffset? ReadLastActivity(ClaimsPrincipal principal) + { + var claim = principal.FindFirst(LastActivityClaimType); + return claim != null && DateTimeOffset.TryParse(claim.Value, out var value) + ? value + : null; } } diff --git a/src/ScadaLink.Security/LdapAuthService.cs b/src/ScadaLink.Security/LdapAuthService.cs index 2ddffb7..62082fc 100644 --- a/src/ScadaLink.Security/LdapAuthService.cs +++ b/src/ScadaLink.Security/LdapAuthService.cs @@ -71,7 +71,7 @@ public class LdapAuthService try { - var searchFilter = $"(uid={EscapeLdapFilter(username)})"; + var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})"; var searchResults = await Task.Run(() => connection.Search( _options.LdapSearchBase, @@ -133,17 +133,13 @@ public class LdapAuthService /// private async Task ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct) { - // If username already looks like a DN, use it as-is - if (username.Contains('=')) - return username; - // If a service account is configured, search for the user's actual DN if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn)) { await Task.Run(() => connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct); - var searchFilter = $"(uid={EscapeLdapFilter(username)})"; + var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})"; var searchResults = await Task.Run(() => connection.Search( _options.LdapSearchBase, @@ -158,13 +154,68 @@ public class LdapAuthService return entry.Dn; } - throw new LdapException("User not found", LdapException.NoSuchObject, $"No entry found for uid={username}"); + throw new LdapException("User not found", LdapException.NoSuchObject, + $"No entry found for {_options.LdapUserIdAttribute}={username}"); } - // Fallback: construct DN directly - return string.IsNullOrWhiteSpace(_options.LdapSearchBase) - ? $"cn={username}" - : $"cn={username},{_options.LdapSearchBase}"; + // Fallback: construct the bind DN directly from the configured user-id + // attribute. The username is RFC 4514 DN-escaped so it cannot alter the + // DN structure (Security-005). The previous Contains('=') shortcut that + // accepted a raw caller-supplied DN has been removed — accepting an + // arbitrary DN from untrusted input let a client choose the bind identity. + return BuildFallbackUserDn(username, _options.LdapSearchBase, _options.LdapUserIdAttribute); + } + + /// + /// Builds the no-service-account fallback bind DN as + /// {userIdAttribute}={escaped-username}[,{searchBase}]. The username is + /// escaped per RFC 4514 so DN metacharacters in untrusted input cannot inject + /// additional RDN components or change the bind identity. + /// + public static string BuildFallbackUserDn(string username, string searchBase, string userIdAttribute) + { + var rdn = $"{userIdAttribute}={EscapeLdapDn(username)}"; + return string.IsNullOrWhiteSpace(searchBase) ? rdn : $"{rdn},{searchBase}"; + } + + /// + /// Escapes a string for use as an RFC 4514 DN attribute value: the special + /// characters , + " \ < > ; are backslash-escaped, as are a leading + /// or trailing space and a leading #. + /// + public static string EscapeLdapDn(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + var sb = new System.Text.StringBuilder(input.Length + 8); + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + var isEdgeSpace = c == ' ' && (i == 0 || i == input.Length - 1); + var isLeadingHash = c == '#' && i == 0; + switch (c) + { + case ',': + case '+': + case '"': + case '\\': + case '<': + case '>': + case ';': + sb.Append('\\').Append(c); + break; + case '\0': + sb.Append("\\00"); + break; + default: + if (isEdgeSpace || isLeadingHash) + sb.Append('\\'); + sb.Append(c); + break; + } + } + return sb.ToString(); } private static string EscapeLdapFilter(string input) diff --git a/src/ScadaLink.Security/SecurityOptions.cs b/src/ScadaLink.Security/SecurityOptions.cs index e6aac5f..909d23d 100644 --- a/src/ScadaLink.Security/SecurityOptions.cs +++ b/src/ScadaLink.Security/SecurityOptions.cs @@ -37,10 +37,19 @@ public class SecurityOptions /// /// Service account DN for LDAP user searches (e.g., "cn=admin,dc=example,dc=com"). /// Required for search-then-bind authentication. If empty, direct bind with - /// cn={username},{LdapSearchBase} is attempted instead. + /// {LdapUserIdAttribute}={username},{LdapSearchBase} is attempted instead. /// public string LdapServiceAccountDn { get; set; } = string.Empty; + /// + /// LDAP attribute that identifies a user. Used both for the search-then-bind + /// filter (({LdapUserIdAttribute}={username})) and for constructing the + /// fallback bind DN when no service account is configured, so the two + /// authentication modes are interchangeable. Common values: uid (OpenLDAP), + /// sAMAccountName (Active Directory). + /// + public string LdapUserIdAttribute { get; set; } = "uid"; + /// /// Service account password for LDAP user searches. /// diff --git a/tests/ScadaLink.Security.Tests/UnitTest1.cs b/tests/ScadaLink.Security.Tests/UnitTest1.cs index 23c53a3..6f5c58c 100644 --- a/tests/ScadaLink.Security.Tests/UnitTest1.cs +++ b/tests/ScadaLink.Security.Tests/UnitTest1.cs @@ -475,6 +475,203 @@ public class SecurityReviewRegressionTests #endregion +#region Code Review Regression Tests — Security-004/005/006/007 + +/// +/// Regression tests for Security-004 (uid/cn attribute mismatch between search filter +/// and fallback DN), Security-005 (DN injection in the no-service-account fallback), +/// Security-006 (JWT issuer/audience checks disabled), and Security-007 (idle-timeout +/// claim reset on every token refresh). +/// +public class SecurityReviewRegressionTests2 +{ + private static SecurityOptions JwtOptions() => new() + { + JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", + JwtExpiryMinutes = 15, + IdleTimeoutMinutes = 30, + JwtRefreshThresholdMinutes = 5 + }; + + private static JwtTokenService CreateJwtService(SecurityOptions? options = null) => + new(Options.Create(options ?? JwtOptions()), NullLogger.Instance); + + // --- Security-004: search filter and fallback DN must use the same attribute --- + + [Fact] + public void BuildFallbackUserDn_UsesConfiguredUserIdAttribute() + { + // The default user-id attribute is "uid"; the fallback DN must use it, + // not a hard-coded "cn", so search-then-bind and direct-bind are interchangeable. + var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "uid"); + Assert.Equal("uid=alice,dc=example,dc=com", dn); + } + + [Fact] + public void BuildFallbackUserDn_HonoursNonDefaultUserIdAttribute() + { + var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "sAMAccountName"); + Assert.Equal("sAMAccountName=alice,dc=example,dc=com", dn); + } + + [Fact] + public void SecurityOptions_LdapUserIdAttribute_DefaultsToUid() + { + Assert.Equal("uid", new SecurityOptions().LdapUserIdAttribute); + } + + // --- Security-005: DN-component escaping must be applied to the username --- + + [Fact] + public void BuildFallbackUserDn_EscapesDnMetacharacters() + { + // A hostile username must not be able to alter the DN structure: the comma + // that would otherwise start a new RDN ("ou=admins") must be escaped so the + // whole string remains a single RDN value. + var dn = LdapAuthService.BuildFallbackUserDn("victim,ou=admins", "dc=example,dc=com", "uid"); + Assert.Equal(@"uid=victim\,ou=admins,dc=example,dc=com", dn); + // The comma from the username is backslash-escaped, so it does not act as an + // RDN separator: the only unescaped comma is the one joining RDN and base DN. + Assert.Contains(@"victim\,ou=admins", dn); + } + + [Fact] + public void EscapeLdapDn_EscapesAllRfc4514Specials() + { + var escaped = LdapAuthService.EscapeLdapDn("a,b+c\"d\\eg;h"); + Assert.Equal(@"a\,b\+c\""d\\e\g\;h", escaped); + } + + [Fact] + public void EscapeLdapDn_EscapesLeadingAndTrailingSpaces() + { + Assert.Equal(@"\ x \ ", LdapAuthService.EscapeLdapDn(" x ")); + } + + // --- Security-006: JWT issuer/audience must be bound and validated --- + + [Fact] + public void GenerateToken_SetsIssuerAndAudience() + { + var service = CreateJwtService(); + var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + + var jwt = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().ReadJwtToken(token); + Assert.Equal(JwtTokenService.TokenIssuer, jwt.Issuer); + Assert.Contains(JwtTokenService.TokenAudience, jwt.Audiences); + } + + [Fact] + public void ValidateToken_RejectsTokenWithWrongIssuer() + { + // A token signed with the same key but a foreign issuer must be rejected. + var options = JwtOptions(); + var key = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey( + System.Text.Encoding.UTF8.GetBytes(options.JwtSigningKey)); + var creds = new Microsoft.IdentityModel.Tokens.SigningCredentials( + key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256); + var foreign = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken( + issuer: "some-other-system", + audience: JwtTokenService.TokenAudience, + claims: new[] { new Claim(JwtTokenService.UsernameClaimType, "user") }, + expires: DateTime.UtcNow.AddMinutes(10), + signingCredentials: creds); + var foreignToken = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().WriteToken(foreign); + + var service = CreateJwtService(options); + Assert.Null(service.ValidateToken(foreignToken)); + } + + [Fact] + public void ValidateToken_AcceptsOwnIssuerAndAudience() + { + var service = CreateJwtService(); + var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + Assert.NotNull(service.ValidateToken(token)); + } + + // --- Security-007: refresh must preserve the original LastActivity timestamp --- + + [Fact] + public void RefreshToken_PreservesOriginalLastActivityClaim() + { + var service = CreateJwtService(); + + // Mint a token whose LastActivity is 20 minutes in the past (still inside the + // 30-minute idle window). Refresh must NOT move it forward to "now". + var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20); + var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(JwtTokenService.DisplayNameClaimType, "User"), + new Claim(JwtTokenService.UsernameClaimType, "user"), + new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) + }, "test")); + + var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); + Assert.NotNull(refreshed); + + var refreshedPrincipal = service.ValidateToken(refreshed!); + Assert.NotNull(refreshedPrincipal); + var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType); + Assert.NotNull(claim); + Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried)); + // The carried timestamp must equal the original, not "now". + Assert.True(Math.Abs((carried - staleActivity).TotalSeconds) < 2, + $"LastActivity was reset on refresh: expected ~{staleActivity:o}, got {carried:o}"); + } + + [Fact] + public void RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle() + { + // A user idle for 25 of the 30-minute window: a refresh fired by some background + // request must not make IsIdleTimedOut flip back to false forever. + var service = CreateJwtService(); + var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-25); + var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(JwtTokenService.DisplayNameClaimType, "User"), + new Claim(JwtTokenService.UsernameClaimType, "user"), + new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) + }, "test")); + + var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); + var refreshedPrincipal = service.ValidateToken(refreshed!); + + // Still 25 min idle after refresh — not reset to 0. + Assert.False(service.IsIdleTimedOut(refreshedPrincipal!)); // 25 < 30, still valid + var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType); + Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried)); + Assert.True((DateTimeOffset.UtcNow - carried).TotalMinutes > 20, + "Refresh wrongly reset the idle clock to ~now"); + } + + [Fact] + public void RecordActivity_UpdatesLastActivityToNow() + { + // Genuine user activity (a real request) — distinct from a token refresh — + // updates LastActivity to the current time. + var service = CreateJwtService(); + var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20); + var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(JwtTokenService.DisplayNameClaimType, "User"), + new Claim(JwtTokenService.UsernameClaimType, "user"), + new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) + }, "test")); + + var touched = service.RecordActivity(principal, new[] { "Admin" }, null); + Assert.NotNull(touched); + + var touchedPrincipal = service.ValidateToken(touched!); + var claim = touchedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType); + Assert.True(DateTimeOffset.TryParse(claim!.Value, out var updated)); + Assert.True((DateTimeOffset.UtcNow - updated).TotalSeconds < 5, + "RecordActivity should set LastActivity to ~now"); + } +} + +#endregion + #region WP-9: Authorization Policy Tests public class AuthorizationPolicyTests