Skip to content

Commit db032c5

Browse files
committed
GRANT / REVOKE / DENY and various other permissions features are now generally enforced.
1 parent d9279e2 commit db032c5

39 files changed

Lines changed: 2685 additions & 217 deletions

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ Field rosters live in the source XML docs; this captures only identity + load-be
135135
Holds the object dicts (`HeapTables` / `Functions` / `Views` / `Procedures` / `Sequences` / `Triggers` — DML triggers share the object namespace) + the type namespace (`TableTypes` / `AliasTypes` / `XmlSchemaCollections`).
136136
Schema-qualified refs route through `Database.Schemas[<schema>]`; unqualified falls back to `DefaultSchemaName` (`"dbo"`).
137137
- **`SimulatedDbConnection`** = session.
138-
Holds `@@`-state (`LastIdentity` = `SCOPE_IDENTITY`/`@@IDENTITY`, `LastStatementRowCount` = `@@ROWCOUNT`, `LastErrorNumber` = `@@ERROR`), `CurrentDatabase` / `CurrentTransaction`, per-session `TempTables` (`#foo`, cleared on Dispose), `NestingLevel` (cap 32), `Spid` (≥51), `SessionIsolationLevel`, `LockTimeoutMillis`, `CurrentExecutingThreadId` (same-thread-deadlock detection).
138+
Holds `@@`-state (`LastIdentity` = `SCOPE_IDENTITY`/`@@IDENTITY`, `LastStatementRowCount` = `@@ROWCOUNT`, `LastErrorNumber` = `@@ERROR`), `CurrentDatabase` / `CurrentTransaction`, per-session `TempTables` (`#foo`, cleared on Dispose), `NestingLevel` (cap 32), `Spid` (≥51), `SessionIsolationLevel`, `LockTimeoutMillis`, `CurrentExecutingThreadId` (same-thread-deadlock detection), and `Security` (`SessionSecurityContext`: original login + base database principal + impersonation stack; default = dbo everywhere; read by the identity scalars, mutated by `EXECUTE AS`/`REVERT` and module `WITH EXECUTE AS`, stamped by connection-string / TDS auth; `EffectiveIsDbo` is the restricted-principal `USE` gate and the permission-enforcement bypass — see [`permissions.md`](docs/claude/permissions.md)).
139139
Full roster in the source XML docs.
140140
- **`BatchContext`** (internal, `Parser/`) = one command execution.
141141
Owns the `ParserContext` (parse-time scratch) + batch-lifetime runtime state: `Variables`, `TableVariables` (`@t`), `CurrentUndoLog`, `CurrentTableVarUndoLog` (statement-only, disjoint from the tx-scoped log so `ROLLBACK TRAN` skips `@t`), `UdfFrame` / `ProcFrame` (non-null in a UDF/proc body — gates value-form `RETURN`).
@@ -261,7 +261,7 @@ Each entry below is a trigger: read the linked file on demand when working in th
261261
- **Locking, MVCC, SNAPSHOT/RCSI, deadlock/timeout, lock-related DMVs**[`locking.md`](docs/claude/locking.md).
262262
- **Application locks** (`sp_getapplock` / `sp_releaseapplock` / `APPLOCK_MODE` / `APPLOCK_TEST`, return-code-vs-raised-error asymmetry, EF 9/10 `Database.Migrate()`'s `__EFMigrationsLock`) → [`app-locks.md`](docs/claude/app-locks.md).
263263
- **`hierarchyid` type** (OrdPath storage, byte-identical CAST/wire/DATALENGTH, tier table + opaque passthrough) → [`hierarchyid.md`](docs/claude/hierarchyid.md).
264-
- **`GRANT` / `REVOKE` / `DENY`, principal DDL (incl. server logins: `CREATE/ALTER/DROP LOGIN`), fixed-principal seed, principal scalars** (`USER_NAME` / `SUSER_SNAME` / `DATABASE_PRINCIPAL_ID` / `CURRENT_USER` / `SESSION_USER` / `ORIGINAL_LOGIN` / `HAS_PERMS_BY_NAME` / `IS_MEMBER`) → [`permissions.md`](docs/claude/permissions.md).
264+
- **`GRANT` / `REVOKE` / `DENY` (real securable resolution + covering/scope-walk enforcement of SELECT/INSERT/UPDATE/DELETE/EXECUTE/TRUNCATE=ALTER/CREATE TABLE, role closure, fixed roles, DENY-beats-GRANT, ownership chaining, WGO/CASCADE writer rules), principal DDL (incl. server logins: `CREATE/ALTER/DROP LOGIN`), fixed-principal + nine-fixed-role seed, principal scalars** (`USER_NAME` / `SUSER_SNAME` / `DATABASE_PRINCIPAL_ID` / `CURRENT_USER` / `SESSION_USER` / `ORIGINAL_LOGIN` / `HAS_PERMS_BY_NAME` / `IS_MEMBER`); dbo bypasses every check[`permissions.md`](docs/claude/permissions.md).
265265
- **`CREATE FULLTEXT CATALOG`/`INDEX`, `CONTAINS`/`FREETEXT` rejection, FULLTEXTCATALOGPROPERTY / FULLTEXTSERVICEPROPERTY**[`full-text.md`](docs/claude/full-text.md).
266266
- **`xml` type, XML schema collections, XML methods (`.value()` / `.nodes()` / `.query()` / `.exist()` via an XQuery-subset evaluator; `.modify()` XML-DML skip-with-diagnostic), XML indexes**[`xml.md`](docs/claude/xml.md).
267267
- **`geography` / `geometry` types, spatial methods, spatial indexes**[`spatial.md`](docs/claude/spatial.md).

docs/claude/permissions.md

Lines changed: 97 additions & 31 deletions
Large diffs are not rendered by default.

src/SqlServerSimulator/BuiltInResources.Security.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ void Sys(string name, HeapColumn[] columns, Func<Parser.BatchContext, Database,
1313
// Pushdown-aware sys.<view> (see BuiltInResources.CoreObjects.cs::SysP).
1414
void SysP(string name, HeapColumn[] columns, string[] pushdownColumns, Func<Parser.BatchContext, Database, CatalogFilter, IEnumerable<SqlValue[]>> filtered) =>
1515
views["sys." + name] = new CatalogView(name, columns, (batch, database) => filtered(batch, database, CatalogFilter.None), filteredRowGenerator: filtered, pushdownColumns: pushdownColumns);
16+
var charFour = CharSqlType.Get(4, Collation.Catalog, Coercibility.Implicit);
1617
// sys.dm_tran_locks: per-Hold rows across every schema-bound
1718
// SchemaLock, every HeapTable.TableDataLock, and every per-row
1819
// entry in HeapTable.RowLocks. GRANT entries come from
@@ -144,7 +145,7 @@ void SysP(string name, HeapColumn[] columns, string[] pushdownColumns, Func<Pars
144145
new("minor_id", SqlType.Int32, null, false),
145146
new("grantee_principal_id", SqlType.Int32, null, false),
146147
new("grantor_principal_id", SqlType.Int32, null, false),
147-
new("type", charTwo, 2, false),
148+
new("type", charFour, 4, false),
148149
new("permission_name", nvarchar128Catalog, 128, true),
149150
new("state", charOne, 1, false),
150151
new("state_desc", nvarchar60Catalog, 60, true),
@@ -221,7 +222,7 @@ void SysP(string name, HeapColumn[] columns, string[] pushdownColumns, Func<Pars
221222
new("minor_id", SqlType.Int32, null, false),
222223
new("grantee_principal_id", SqlType.Int32, null, false),
223224
new("grantor_principal_id", SqlType.Int32, null, false),
224-
new("type", charTwo, 2, false),
225+
new("type", charFour, 4, false),
225226
new("permission_name", nvarchar128Catalog, 128, true),
226227
new("state", charOne, 1, false),
227228
new("state_desc", nvarchar60Catalog, 60, true),
@@ -317,7 +318,6 @@ void SysP(string name, HeapColumn[] columns, string[] pushdownColumns, Func<Pars
317318
// carries. server_audits /
318319
// server_file_audits / cryptographic_providers are server-scoped (the
319320
// row generator ignores the database). See docs/claude/catalog-views.md.
320-
var charFour = CharSqlType.Get(4, Collation.Catalog, Coercibility.Implicit);
321321
Sys("symmetric_keys",
322322
[
323323
new("name", SqlType.SystemName, 128, false),
@@ -558,8 +558,8 @@ private static IEnumerable<SqlValue[]> EnumerateSysDatabasePrincipals(Parser.Bat
558558

559559
private static IEnumerable<SqlValue[]> EnumerateSysDatabasePermissions(Parser.BatchContext batch, Database database)
560560
{
561-
var charTwo = SqlType.GetChar(2);
562-
var charOne = SqlType.GetChar(1);
561+
var typeChar = SqlType.GetChar(4);
562+
var stateChar = SqlType.GetChar(1);
563563
foreach (var perm in database.Permissions)
564564
{
565565
var classDesc = perm.Class switch
@@ -585,9 +585,9 @@ private static IEnumerable<SqlValue[]> EnumerateSysDatabasePermissions(Parser.Ba
585585
SqlValue.FromInt32(perm.MinorId),
586586
SqlValue.FromInt32(perm.GranteePrincipalId),
587587
SqlValue.FromInt32(perm.GrantorPrincipalId),
588-
SqlValue.FromChar(charTwo, perm.TypeCode),
588+
SqlValue.FromChar(typeChar, perm.TypeCode),
589589
SqlValue.FromNVarchar(perm.PermissionName),
590-
SqlValue.FromChar(charOne, perm.State),
590+
SqlValue.FromChar(stateChar, perm.State),
591591
SqlValue.FromNVarchar(stateDesc),
592592
];
593593
}

src/SqlServerSimulator/Database.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ internal sealed class Database
1818
/// <summary>The schema name an unqualified table reference resolves through.</summary>
1919
public const string DefaultSchemaName = "dbo";
2020

21+
/// <summary>
22+
/// Principal id of the database-owning <c>dbo</c> user (1, matching real
23+
/// SQL Server). The identity the permission layer treats as the
24+
/// bypass-everything owner: a session whose effective principal is this id
25+
/// short-circuits every check. Seeded in the constructor's fixed-principal
26+
/// block and read by <see cref="SessionSecurityContext"/> and the
27+
/// principal scalars in place of a scattered literal <c>1</c>.
28+
/// </summary>
29+
public const int DboPrincipalId = 1;
30+
31+
/// <summary>Principal id of the <c>guest</c> user (2) — the fallback identity for a mapped login connecting to <c>master</c>.</summary>
32+
public const int GuestPrincipalId = 2;
33+
2134
/// <summary>Database name (the key in <see cref="Simulation.Databases"/>).</summary>
2235
public readonly string Name;
2336

@@ -69,8 +82,35 @@ public Database(string name, Collation collation)
6982
this.Principals["guest"] = new DatabasePrincipal(2, "guest", "S", "SQL_USER", isFixedRole: false, seedDate);
7083
this.Principals["INFORMATION_SCHEMA"] = new DatabasePrincipal(3, "INFORMATION_SCHEMA", "S", "SQL_USER", isFixedRole: false, seedDate);
7184
this.Principals["sys"] = new DatabasePrincipal(4, "sys", "S", "SQL_USER", isFixedRole: false, seedDate);
85+
// The nine fixed database roles, with real SQL Server's principal ids
86+
// (probe-confirmed 2026-07-21). 16388 is deliberately absent — real
87+
// skips it. All type R, is_fixed_role, owned by dbo. Membership is
88+
// tracked in RoleMembers like any role; the permission checker reads
89+
// the closure and gives db_owner / db_datareader / db_datawriter /
90+
// db_ddladmin / db_denydatareader / db_denydatawriter their virtual
91+
// capabilities.
92+
foreach (var (id, roleName) in FixedDatabaseRoles)
93+
this.Principals[roleName] = new DatabasePrincipal(id, roleName, "R", "DATABASE_ROLE", isFixedRole: true, seedDate);
7294
}
7395

96+
/// <summary>
97+
/// The nine fixed database roles and their real-SQL-Server principal ids.
98+
/// Seeded into <see cref="Principals"/> at construction; consulted by the
99+
/// permission checker's fixed-role capability rules.
100+
/// </summary>
101+
public static readonly (int Id, string Name)[] FixedDatabaseRoles =
102+
[
103+
(16384, "db_owner"),
104+
(16385, "db_accessadmin"),
105+
(16386, "db_securityadmin"),
106+
(16387, "db_ddladmin"),
107+
(16389, "db_backupoperator"),
108+
(16390, "db_datareader"),
109+
(16391, "db_datawriter"),
110+
(16392, "db_denydatareader"),
111+
(16393, "db_denydatawriter"),
112+
];
113+
74114
/// <summary>
75115
/// Convenience accessor for the <c>dbo</c> schema's tables — the
76116
/// unqualified-reference fallback path. Equivalent to

src/SqlServerSimulator/DatabasePrincipal.cs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,42 @@ internal sealed class DatabasePrincipal(
2323
string typeCode,
2424
string typeDescription,
2525
bool isFixedRole,
26-
DateTime createDate)
26+
DateTime createDate,
27+
string? loginName = null,
28+
string? securityIdentifierString = null)
2729
{
2830
public readonly int PrincipalId = principalId;
2931
public readonly string Name = name;
3032

33+
/// <summary>
34+
/// The server login this database user is mapped to
35+
/// (<c>CREATE USER name FOR LOGIN login</c>), or <c>null</c> when the user
36+
/// carries no login link (WITHOUT LOGIN users, the fixed principals, and
37+
/// the parse-and-discard <c>CREATE USER</c> forms). Drives login →
38+
/// database-user resolution at connect time and the
39+
/// <c>SYSTEM_USER</c> / <c>SUSER_SNAME()</c> value while impersonating this
40+
/// user.
41+
/// </summary>
42+
public readonly string? LoginName = loginName;
43+
44+
/// <summary>
45+
/// The synthetic <c>S-1-9-3-…</c> security-identifier string a
46+
/// <c>CREATE USER name WITHOUT LOGIN</c> user reports through
47+
/// <c>SYSTEM_USER</c> / <c>SUSER_SNAME()</c> and the Msg 916 "server
48+
/// principal" wording (real SQL Server has no login name for these users,
49+
/// only a SID). Deterministically derived from the user name;
50+
/// <c>null</c> for every other principal.
51+
/// </summary>
52+
public readonly string? SecurityIdentifierString = securityIdentifierString;
53+
54+
/// <summary>
55+
/// The identity string a session impersonating this database user reports
56+
/// through <c>SYSTEM_USER</c> / <c>SUSER_SNAME()</c>: the mapped login when
57+
/// one exists, the synthetic SID for WITHOUT LOGIN users, else the user
58+
/// name itself.
59+
/// </summary>
60+
public string EffectiveLoginIdentity => this.LoginName ?? this.SecurityIdentifierString ?? this.Name;
61+
3162
/// <summary>
3263
/// One- or two-character <c>sys.database_principals.type</c> code.
3364
/// <c>S</c> = SQL_USER, <c>U</c> = WINDOWS_USER, <c>G</c> = WINDOWS_GROUP,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
namespace SqlServerSimulator;
2+
3+
/// <summary>
4+
/// Permission-enforcement error factories (Msg 229 / 262 / 1088 / 4606 /
5+
/// 4611). The 15151 object-variant and the impersonation errors (15517 /
6+
/// 15406) live in <c>SimulatedSqlException.SchemaErrors.cs</c> alongside the
7+
/// principal-resolution factories.
8+
/// </summary>
9+
public sealed partial class SimulatedSqlException
10+
{
11+
/// <summary>
12+
/// Mimics SQL Server error 229: a DML / EXECUTE permission was denied on an
13+
/// object. Severity 14, state 5, probe-confirmed wording. For an EXEC-proc
14+
/// denial <paramref name="procedure"/> carries the schema-qualified proc
15+
/// name (surfaces through <c>ERROR_PROCEDURE()</c>), matching real; empty
16+
/// for table / view / TVF denials.
17+
/// </summary>
18+
internal static SimulatedSqlException PermissionDenied(string permission, string objectName, string databaseName, string schemaName, string procedure = "") =>
19+
new($"The {permission} permission was denied on the object '{objectName}', database '{databaseName}', schema '{schemaName}'.",
20+
new SimulatedError(@class: 14, lineNumber: 0,
21+
message: $"The {permission} permission was denied on the object '{objectName}', database '{databaseName}', schema '{schemaName}'.",
22+
number: 229, procedure: procedure, server: SimulatedDbConnection.DataSourceName, source: SourceName, state: 5));
23+
24+
/// <summary>
25+
/// Mimics SQL Server error 262: <c>CREATE TABLE</c> attempted by a principal
26+
/// lacking <c>db_ddladmin</c> / <c>db_owner</c> membership (or an explicit
27+
/// CREATE TABLE grant). Severity 14, state 1, probe-confirmed wording.
28+
/// </summary>
29+
internal static SimulatedSqlException CreateTablePermissionDenied(string databaseName) =>
30+
new($"CREATE TABLE permission denied in database '{databaseName}'.", 262, 14, 1);
31+
32+
/// <summary>
33+
/// Mimics SQL Server error 1088: a TRUNCATE (which requires ALTER on the
34+
/// object) was denied. Distinct shape from Msg 229 — double-quoted name,
35+
/// severity 16, state 7, probe-confirmed wording.
36+
/// </summary>
37+
internal static SimulatedSqlException CannotFindObjectForAlter(string objectLeafName) =>
38+
new($"Cannot find the object \"{objectLeafName}\" because it does not exist or you do not have permissions.", 1088, 16, 7);
39+
40+
/// <summary>
41+
/// Mimics SQL Server error 4606: a permission is incompatible with the
42+
/// securable's object kind (SELECT on a procedure, EXECUTE on a table /
43+
/// view / TVF). Severity 16, probe-confirmed wording.
44+
/// </summary>
45+
internal static SimulatedSqlException PermissionIncompatibleWithObject(string permission) =>
46+
new($"Granted or revoked privilege {permission} is not compatible with object.", 4606, 16, 1);
47+
48+
/// <summary>
49+
/// Mimics SQL Server error 4611: a plain <c>REVOKE</c> (or REVOKE GRANT
50+
/// OPTION FOR) of a grantable (<c>WITH GRANT OPTION</c>) permission that has
51+
/// live delegations, without the CASCADE option. Severity 16, catchable,
52+
/// probe-confirmed wording.
53+
/// </summary>
54+
internal static SimulatedSqlException RevokeRequiresCascade() =>
55+
new("To revoke or deny grantable privileges, specify the CASCADE option.", 4611, 16, 1);
56+
}

src/SqlServerSimulator/Errors/SimulatedSqlException.SchemaErrors.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,6 +1150,55 @@ internal static SimulatedSqlException ServerPrincipalAlreadyExists(string name)
11501150
internal static SimulatedSqlException CannotAlterOrDropLogin(string verb, string name) =>
11511151
new($"Cannot {verb} the login '{name}', because it does not exist or you do not have permission.", 15151, 16, 1);
11521152

1153+
/// <summary>
1154+
/// Mimics SQL Server error 15517: <c>EXECUTE AS USER = 'x'</c> when the
1155+
/// target database principal doesn't exist, can't be impersonated, or the
1156+
/// caller lacks IMPERSONATE. The name is double-quoted. Probe-confirmed
1157+
/// (2026-07-21) — including the quirk that <c>EXECUTE AS USER = 'dbo'</c>
1158+
/// raises this even for a sysadmin session.
1159+
/// </summary>
1160+
internal static SimulatedSqlException CannotExecuteAsDatabasePrincipal(string name) =>
1161+
new($"Cannot execute as the database principal because the principal \"{name}\" does not exist, this type of principal cannot be impersonated, or you do not have permission.", 15517, 16, 1);
1162+
1163+
/// <summary>
1164+
/// Mimics SQL Server error 15406: <c>EXECUTE AS LOGIN = 'l'</c> when the
1165+
/// target server principal doesn't exist, can't be impersonated, or the
1166+
/// caller lacks IMPERSONATE. The name is double-quoted. Probe-confirmed
1167+
/// (2026-07-21).
1168+
/// </summary>
1169+
internal static SimulatedSqlException CannotExecuteAsServerPrincipal(string name) =>
1170+
new($"Cannot execute as the server principal because the principal \"{name}\" does not exist, this type of principal cannot be impersonated, or you do not have permission.", 15406, 16, 1);
1171+
1172+
/// <summary>
1173+
/// Mimics SQL Server error 916: a session running under a restricted
1174+
/// security context (an impersonated database user, or an authenticated
1175+
/// login mapped to a non-<c>dbo</c> user) tried to switch databases via
1176+
/// <c>USE</c> / <c>ChangeDatabase</c>. The principal name is a login name,
1177+
/// or the WITHOUT-LOGIN user's <c>S-1-9-3-…</c> SID string. Session stays in
1178+
/// the current database. Probe-confirmed (2026-07-21): severity 14, state 2.
1179+
/// </summary>
1180+
internal static SimulatedSqlException CannotAccessDatabaseUnderSecurityContext(string principalName, string databaseName) =>
1181+
new($"The server principal \"{principalName}\" is not able to access the database \"{databaseName}\" under the current security context.", 916, 14, 2);
1182+
1183+
/// <summary>
1184+
/// Mimics SQL Server error 18456: an in-process connection-string login
1185+
/// (<c>User ID=</c>) failed to authenticate against the <c>CREATE LOGIN</c>
1186+
/// registry. Same severity-14 / state-1 shape the TDS endpoint writes
1187+
/// (which emits the token directly rather than through this factory).
1188+
/// </summary>
1189+
internal static SimulatedSqlException LoginFailed(string userName) =>
1190+
new($"Login failed for user '{userName}'.", 18456, 14, 1);
1191+
1192+
/// <summary>
1193+
/// Mimics SQL Server error 4060: a login couldn't open the requested
1194+
/// database (missing, or not accessible to the mapped principal). The
1195+
/// database name is double-quoted, severity 11. On the wire this is
1196+
/// followed by a Msg 18456; the in-process front door surfaces the 4060
1197+
/// alone (single-error divergence documented in <c>permissions.md</c>).
1198+
/// </summary>
1199+
internal static SimulatedSqlException CannotOpenDatabaseRequestedByLogin(string databaseName) =>
1200+
new($"Cannot open database \"{databaseName}\" requested by the login. The login failed.", 4060, 11, 1);
1201+
11531202
/// <summary>
11541203
/// Mimics SQL Server error 2749: an <c>ALTER COLUMN</c> on an IDENTITY
11551204
/// column tried to change the underlying type to something outside the

0 commit comments

Comments
 (0)