Skip to content

Commit b02b2ad

Browse files
committed
Switched permissions internals to enum-based from string-based.
1 parent db032c5 commit b02b2ad

10 files changed

Lines changed: 339 additions & 206 deletions

docs/claude/permissions.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ Logins are enforced as connection credentials at both front doors (TDS endpoint
1919
- `SecurityIdentifierString` (string?) — the deterministic `S-1-9-3-…` SID a `CREATE USER … WITHOUT LOGIN` user reports through `SYSTEM_USER` / Msg 916 (FNV-derived from the name).
2020
- `EffectiveLoginIdentity` — the `SYSTEM_USER` value while impersonating this user (login ?? SID ?? name).
2121

22-
**`DatabasePermission`** (`src/SqlServerSimulator/DatabasePermission.cs`) carries class + major_id + minor_id + grantee/grantor ids + permission_name + 4-char type code + state (`G`/`W`/`D`/`R`).
22+
**`DatabasePermission`** (`src/SqlServerSimulator/DatabasePermission.cs`) carries class + major_id + minor_id + grantee/grantor ids + a `Permission` enum + a `PermissionState` enum (Grant / GrantWithGrantOption / Deny / Revoke, projecting the `G`/`W`/`D`/`R` state codes).
23+
Canonical rows draw their `permission_name` and 4-char `type` code from `PermissionCatalog` at projection; off-catalog names (`Permission.Other`) carry their raw text on `PermissionName` and are never matched by a permission check.
24+
`PermissionChecker` compares the enum throughout (closure walk, DENY precedence, covering/scope walk, read/write/DDL fixed-role virtual grants) — no permission-name string comparison remains on any check path; `HAS_PERMS_BY_NAME` / GRANT parsing resolve the incoming name to the enum once at the boundary via `Permission.Resolve` (a zero-alloc span switch, a `PermissionCatalog` static extension member).
25+
The catalog surfaces per-enum lookups as extension members (`permission.CanonicalName` / `.CanonicalTypeCode` / `.Category` / `.Covering(class)`, `state.Code` / `.Description`); row-shaped concerns live on `DatabasePermission` itself (`IsFor` securable+permission identity, `DisplayName` / `DisplayTypeCode` projection).
2326

2427
Both live on `Database`:
2528
- `Database.Principals``ConcurrentDictionary<string, DatabasePrincipal>` keyed by name
@@ -145,9 +148,10 @@ In-process connections never authenticate — login DDL through one is how the r
145148

146149
## Permission type-code derivation
147150

148-
`Simulation.CanonicalPermissionTypeCode` imports the canonical 4-char `sys.database_permissions.type` codes from `sys.fn_builtin_permissions` for the common OBJECT / SCHEMA / DATABASE / DATABASE_PRINCIPAL permissions (`SELECT``SL`, `UPDATE``UP`, `EXECUTE``EX`, `CONTROL``CL`, `IMPERSONATE``IM`, `CREATE TABLE``CRTB`, …).
149-
Codes are stored space-padded to 4 chars and the view's `type` column is `char(4)`, matching real's trailing-space-bearing values (`'SL '`).
150-
Names outside the imported set (AW's `VIEW ANY COLUMN … DEFINITION` grants) fall back to the first-letter-of-each-word heuristic (`VIEW ANY COLUMN MASTER KEY DEFINITION``VACM`), which won't byte-match real for every long name.
151+
`PermissionCatalog` (`src/SqlServerSimulator/Permission.cs`) is the single source of truth: one static table indexed by the `Permission` enum carries each member's canonical name, 4-char `sys.database_permissions.type` code (imported from `sys.fn_builtin_permissions` for the common OBJECT / SCHEMA / DATABASE / DATABASE_PRINCIPAL permissions — `SELECT``SL`, `UPDATE``UP`, `EXECUTE``EX`, `CONTROL``CL`, `IMPERSONATE``IM`, `CREATE TABLE``CRTB`, …), and read/write/DDL category; the covering graph and name→enum resolver live alongside it.
152+
Codes are projected space-padded to 4 chars and the view's `type` column is `char(4)`, matching real's trailing-space-bearing values (`'SL '`).
153+
Names outside the catalog resolve to `Permission.Other` and project their raw stored text plus a first-letter-of-each-word type-code heuristic (`VIEW ANY COLUMN MASTER KEY DEFINITION``VACM`), which won't byte-match real for every long name.
154+
Canonical names project their catalog spelling regardless of the GRANT's casing (real normalizes them the same way).
151155

152156
`class_desc` / `state_desc` are spelled out per the probe-confirmed enum:
153157
- `class_desc`: `DATABASE` / `OBJECT_OR_COLUMN` / `SCHEMA` / `DATABASE_PRINCIPAL`

src/SqlServerSimulator/BuiltInResources.Security.cs

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -570,25 +570,17 @@ private static IEnumerable<SqlValue[]> EnumerateSysDatabasePermissions(Parser.Ba
570570
4 => "DATABASE_PRINCIPAL",
571571
_ => "DATABASE",
572572
};
573-
var stateDesc = perm.State switch
574-
{
575-
"D" => "DENY",
576-
"G" => "GRANT",
577-
"R" => "REVOKE",
578-
"W" => "GRANT_WITH_GRANT_OPTION",
579-
_ => "GRANT",
580-
};
581573
yield return [
582574
SqlValue.FromByte(perm.Class),
583575
SqlValue.FromNVarchar(classDesc),
584576
SqlValue.FromInt32(perm.MajorId),
585577
SqlValue.FromInt32(perm.MinorId),
586578
SqlValue.FromInt32(perm.GranteePrincipalId),
587579
SqlValue.FromInt32(perm.GrantorPrincipalId),
588-
SqlValue.FromChar(typeChar, perm.TypeCode),
589-
SqlValue.FromNVarchar(perm.PermissionName),
590-
SqlValue.FromChar(stateChar, perm.State),
591-
SqlValue.FromNVarchar(stateDesc),
580+
SqlValue.FromChar(typeChar, perm.DisplayTypeCode),
581+
SqlValue.FromNVarchar(perm.DisplayName),
582+
SqlValue.FromChar(stateChar, perm.State.Code),
583+
SqlValue.FromNVarchar(perm.State.Description),
592584
];
593585
}
594586
}
Lines changed: 69 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,32 @@
11
namespace SqlServerSimulator;
22

33
/// <summary>
4-
/// One entry in <see cref="Database.Permissions"/> — a GRANT / REVOKE /
5-
/// DENY statement that's been parsed and stored but never enforced. The
6-
/// simulator has no permission model; this exists to round-trip the
7-
/// information into <c>sys.database_permissions</c>.
4+
/// One entry in <see cref="Database.Permissions"/> — a stored GRANT / DENY
5+
/// (WITH GRANT OPTION) row. Canonical permissions store their identity as a
6+
/// <see cref="SqlServerSimulator.Permission"/> enum and draw their name / type
7+
/// code from <see cref="PermissionCatalog"/> at projection time; off-catalog
8+
/// names store <see cref="SqlServerSimulator.Permission.Other"/> plus their raw
9+
/// text on <see cref="PermissionName"/>.
810
/// </summary>
911
/// <remarks>
10-
/// Real SQL Server permission rows carry a <c>class</c> tinyint
11-
/// distinguishing database-scope (0) from object/column (1), schema (3),
12-
/// principal (4), etc. AW only emits class-0 GRANT statements, so the
13-
/// simulator's <see cref="Class"/> + <see cref="MajorId"/> + <see cref="MinorId"/>
14-
/// triple mirrors the catalog-view shape: class=0, major_id=0,
15-
/// minor_id=0 for database-scope grants; class=1 + major_id=&lt;object_id&gt;
16-
/// for object-scope grants (when the parser eventually accepts <c>ON
17-
/// OBJECT::name</c>).
12+
/// The <see cref="Class"/> + <see cref="MajorId"/> + <see cref="MinorId"/> triple
13+
/// mirrors the catalog-view shape: class=0, major_id=0 for database-scope grants;
14+
/// class=1 + major_id=&lt;object_id&gt; for object-scope; class=3 + schema_id for
15+
/// schema-scope; class=4 + principal_id for the IMPERSONATE gate.
1816
/// </remarks>
1917
internal sealed class DatabasePermission(
2018
byte @class,
2119
int majorId,
2220
int minorId,
2321
int granteePrincipalId,
2422
int grantorPrincipalId,
25-
string permissionName,
26-
string typeCode,
27-
string state)
23+
Permission permission,
24+
PermissionState state,
25+
string? permissionName = null)
2826
{
2927
/// <summary>
3028
/// Permission target class: 0=database, 1=object/column, 3=schema,
31-
/// 4=database principal. The simulator only populates 0 (and 1 once
32-
/// object-scope grants land).
29+
/// 4=database principal.
3330
/// </summary>
3431
public readonly byte Class = @class;
3532

@@ -51,34 +48,70 @@ internal sealed class DatabasePermission(
5148

5249
/// <summary>
5350
/// <see cref="DatabasePrincipal.PrincipalId"/> of the principal that
54-
/// issued the GRANT statement. The simulator has no current-user
55-
/// concept; defaults to <c>dbo</c> (id 1).
51+
/// issued the GRANT statement (the granting session's effective principal;
52+
/// <c>dbo</c> = id 1 for an unimpersonated session).
5653
/// </summary>
5754
public readonly int GrantorPrincipalId = grantorPrincipalId;
5855

5956
/// <summary>
60-
/// Long-form permission name as parsed from the GRANT statement
61-
/// (e.g. <c>VIEW ANY COLUMN ENCRYPTION KEY DEFINITION</c>). Stored
62-
/// case-preserved; matched case-insensitively at REVOKE/DENY time.
57+
/// The canonical permission, or <see cref="SqlServerSimulator.Permission.Other"/>
58+
/// for an off-catalog name carried on <see cref="PermissionName"/>.
6359
/// </summary>
64-
public readonly string PermissionName = permissionName;
60+
public readonly Permission Permission = permission;
6561

6662
/// <summary>
67-
/// 4-character SQL Server permission type code
68-
/// (<c>VWCD</c> for VIEW ANY COLUMN MASTER KEY DEFINITION, etc.).
69-
/// Derived from the first letter of each word in <see cref="PermissionName"/>,
70-
/// right-padded with spaces — accurate for most spelled-out permission
71-
/// names but not all (a small lookup table refinement would be needed
72-
/// for exact catalog parity).
63+
/// Raw permission text for an <see cref="SqlServerSimulator.Permission.Other"/>
64+
/// row (e.g. <c>VIEW ANY COLUMN MASTER KEY DEFINITION</c>), stored
65+
/// case-preserved and matched case-insensitively; <see langword="null"/> for a
66+
/// canonical row, whose name / type code come from <see cref="PermissionCatalog"/>.
7367
/// </summary>
74-
public readonly string TypeCode = typeCode;
68+
public readonly string? PermissionName = permissionName;
7569

7670
/// <summary>
77-
/// State code: <c>G</c>=Grant, <c>R</c>=Revoke, <c>D</c>=Deny,
78-
/// <c>W</c>=Grant_with_grant. Real SQL Server treats REVOKE as a
79-
/// row-deletion rather than a stored state; the simulator keeps the
80-
/// REVOKE row so DENY ⇄ GRANT toggling can be observed in
81-
/// <c>sys.database_permissions</c> for debugging.
71+
/// State: Grant / GrantWithGrantOption / Deny / Revoke. Real SQL Server treats
72+
/// REVOKE as a row-deletion; the simulator likewise removes rows on REVOKE and
73+
/// keeps only live Grant / GrantWithGrantOption / Deny rows.
8274
/// </summary>
83-
public readonly string State = state;
75+
public readonly PermissionState State = state;
76+
77+
/// <summary>The catalog-view <c>permission_name</c> — the canonical catalog spelling, or the raw stored text for an off-catalog (<see cref="SqlServerSimulator.Permission.Other"/>) name.</summary>
78+
public string DisplayName => Permission == Permission.Other ? PermissionName! : Permission.CanonicalName;
79+
80+
/// <summary>The catalog-view <c>type</c> code — the canonical 4-char code, or the first-letter heuristic for an off-catalog name.</summary>
81+
public string DisplayTypeCode => Permission == Permission.Other ? DeriveTypeCode(PermissionName!) : Permission.CanonicalTypeCode;
82+
83+
/// <summary>
84+
/// Whether this row names <paramref name="permission"/> on the
85+
/// (<paramref name="securableClass"/>, <paramref name="majorId"/>) securable.
86+
/// Enum equality except for <see cref="SqlServerSimulator.Permission.Other"/>,
87+
/// where collation name-equality against <paramref name="permissionName"/>
88+
/// keeps two distinct off-catalog names from colliding.
89+
/// </summary>
90+
public bool IsFor(byte securableClass, int majorId, Permission permission, string permissionName, Database database) =>
91+
Class == securableClass
92+
&& MajorId == majorId
93+
&& Permission == permission
94+
&& (permission != Permission.Other || database.Collation.Equals(PermissionName, permissionName));
95+
96+
/// <summary>
97+
/// First-letter-of-each-word type-code heuristic for off-catalog permission
98+
/// names (e.g. <c>VIEW ANY COLUMN MASTER KEY DEFINITION</c> → <c>VACM</c>),
99+
/// right-padded with spaces to 4 chars. Accurate for most spelled-out names
100+
/// but won't byte-match real for every long name.
101+
/// </summary>
102+
private static string DeriveTypeCode(string permissionName)
103+
{
104+
var words = permissionName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
105+
Span<char> code = stackalloc char[4];
106+
var idx = 0;
107+
foreach (var w in words)
108+
{
109+
if (idx >= 4)
110+
break;
111+
code[idx++] = char.ToUpperInvariant(w[0]);
112+
}
113+
while (idx < 4)
114+
code[idx++] = ' ';
115+
return new string(code);
116+
}
84117
}

src/SqlServerSimulator/Parser/Expressions/PrincipalIdScalars.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ public override SqlValue Run(RuntimeContext runtime)
264264
}
265265

266266
return SqlValue.FromInt32(
267-
PermissionChecker.IsGranted(database, principalId, permission, securableClass, majorId, schemaId) ? 1 : 0);
267+
PermissionChecker.IsGranted(database, principalId, Permission.Resolve(permission), securableClass, majorId, schemaId) ? 1 : 0);
268268
}
269269

270270
private static bool TryResolveSchemaByName(Database database, string name, out int schemaId)

0 commit comments

Comments
 (0)