Skip to content

Add constexpr security descriptor building#628

Open
jonwis wants to merge 7 commits intomicrosoft:masterfrom
jonwis:user/jonwis/static-sddl
Open

Add constexpr security descriptor building#628
jonwis wants to merge 7 commits intomicrosoft:masterfrom
jonwis:user/jonwis/static-sddl

Conversation

@jonwis
Copy link
Copy Markdown
Member

@jonwis jonwis commented Apr 1, 2026

We have a lot of code in various startup paths that uses ConvertStringSecurityDescriptorToSecurityDescriptor, or worse, does hand-building of SECURITY_DESCRIPTOR with building all the ACEs, parsing SID strings, etc.

This introduces a constexpr SD builder, skipping all that and building a self-relative SD at compile time. So now you can say things like:

constexpr auto sd = wil::make_self_relative_sd(
        wil::sd_owner<"S-1-5-32-544">(),                     // BUILTIN\Administrators
        wil::sd_group<"S-1-5-32-545">(),                     // BUILTIN\Users
        wil::make_deny_ace<"S-1-5-7">(GENERIC_WRITE),        // deny ANONYMOUS LOGON
        wil::make_allow_ace<"S-1-5-11">(GENERIC_READ));       // allow Authenticated Users

jonwis added 6 commits April 1, 2026 15:15
…elf_relative_sd(), make_allow_ace(), make_deny_ace(),\nsd_owner(), sd_group(), and no_sid for composing self-relative\nsecurity descriptors at compile time from static SIDs and typed\nACEs. C++20 only, gated behind _HAS_CXX20.\n\nSupports allow/deny ACE types, per-ACE inheritance flags, and\noptional owner/group SIDs. Deny-before-allow ordering enforced\nvia WI_ASSERT."
…n\nParse SID string literals (e.g. \"S-1-5-32-544\") into static_sid_t\nat compile time via C++20 class-type NTTPs. The resulting SID works\nwith all existing helpers: sd_owner, sd_group, make_allow_ace, etc.\n\nGuarded behind __WIL_HAS_CLASS_NTTP which checks for class-type\nNTTP support on both MSVC and Clang."
size_t count = 0;
for (size_t i = 0; i < S.length; ++i)
{
if (S.data[i] == '-')
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Style standard is usually to always have { } even for single line if blocks.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair - i'll ask GHCP why it didn't follow the clang-format conventions.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like InsertBraces: true was added in Clang 15, but our .clang-format doesn't include it.

uint64_t value = 0;
while (pos < len && str[pos] >= '0' && str[pos] <= '9')
{
value = value * 10 + static_cast<uint64_t>(str[pos] - '0');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to glare at this for a minute to be sure order of operations was intended. Consider adding ( ) around the multiply to make it obviously-intentional.

Comment on lines +558 to +561
static constexpr size_t byte_size()
{
return 8 + 4 * AuthorityCount;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a constexpr variable... no reason for a function here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.g. similar to how npos works

return make_static_sid(SECURITY_NT_AUTHORITY, wistd::forward<Ts>(subAuthorities)...);
}

#ifdef _HAS_CXX20
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't rely on this macro. Use __WI_LIBCPP_STD_VER

#ifdef _HAS_CXX20

// Class-type NTTPs: MSVC defines __cpp_nontype_template_args; Clang supports it in C++20+ mode without the macro.
#if __cpp_nontype_template_args >= 201911L || (defined(__clang__) && __clang_major__ >= 12 && _HAS_CXX20)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From https://releases.llvm.org/18.1.6/tools/clang/docs/ReleaseNotes.html#c-20-feature-support:

Implemented P1907R1 which extends allowed non-type template argument kinds with e.g. floating point values and pointers and references to subobjects. This feature is still experimental. Accordingly, __cpp_nontype_template_args was not updated. However, its support can be tested with __has_extension(cxx_generalized_nttp).

Sounds like it's support is experimental. That's probably okay, though it does give me pause. In any case, we should possibly do the check it suggests instead of querying for the version

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I dunno - I thought it could do something similar to how format works. I need to goad it harder probably.

#ifdef _HAS_CXX20

// Class-type NTTPs: MSVC defines __cpp_nontype_template_args; Clang supports it in C++20+ mode without the macro.
#if __cpp_nontype_template_args >= 201911L || (defined(__clang__) && __clang_major__ >= 12 && _HAS_CXX20)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You also make use of consteval and should probably check __cpp_consteval as well


// Count '-' characters in a fixed_string.
template <fixed_string S>
consteval size_t count_dashes()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be consteval? Also any reason not to make this a member function?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And if you change them to be constexpr should probably make them all noexcept for good hygiene

}

// Parse an unsigned decimal integer from a string starting at pos, advancing pos past the digits.
consteval uint64_t parse_uint(const char* str, size_t len, size_t& pos)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably another function that doesn't need to be consteval.

Interesting side note: looks like std::from_chars became constexpr in C++23. Obviously can't rely on that here, just interesting.

{
static_assert(sizeof...(aces) > 0, "at least one ACE is required");

constexpr size_t sdHeaderSize = 20; // SECURITY_DESCRIPTOR_RELATIVE header
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an issue with using sizeof(SECURITY_DESCRIPTOR_RELATIVE) that's not immediately obvious?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it's because of the manual math when writing down below. Probably still good for a static_assert though


// Parse a SID string "S-1-{authority}-{sub1}-{sub2}-..." into a static_sid_t.
template <fixed_string S>
consteval auto parse_sid_string()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems more ergonomic to make this a static from_string or something member of static_sid_t. You'd probably need to move some definitions around a bit though

{
// "S-R-IA" has 2 dashes; each sub-authority adds one more.
constexpr size_t subAuthCount = count_dashes<S>() - 2;
static_assert(subAuthCount <= SID_MAX_SUB_AUTHORITIES, "too many sub authorities in SID string");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A potentially confusing error message if you get integer underflow (i.e. fewer than 2 dashes)

Comment on lines +678 to +680
// Expect 'S' or 's'
++pos; // skip 'S'
++pos; // skip '-'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to check things like this, and the dashes below (throw exceptions to fail at compile time). Probably unlikely, but we all make silly mistakes from time to time

++pos; // skip '-'

// Revision
result.Revision = static_cast<uint8_t>(parse_uint(S.data, S.length, pos));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe templatize the integer type for parse_uint.

Would also be ideal if things like integer overflow could fail the build

Comment on lines +710 to +727
constexpr void write_byte(uint8_t* dest, size_t& offset, uint8_t value)
{
dest[offset++] = value;
}

constexpr void write_word(uint8_t* dest, size_t& offset, uint16_t value)
{
dest[offset++] = static_cast<uint8_t>(value & 0xFF);
dest[offset++] = static_cast<uint8_t>((value >> 8) & 0xFF);
}

constexpr void write_dword(uint8_t* dest, size_t& offset, uint32_t value)
{
dest[offset++] = static_cast<uint8_t>(value & 0xFF);
dest[offset++] = static_cast<uint8_t>((value >> 8) & 0xFF);
dest[offset++] = static_cast<uint8_t>((value >> 16) & 0xFF);
dest[offset++] = static_cast<uint8_t>((value >> 24) & 0xFF);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

template <typename T>
constexpr void write_integral(uint8_t* dest, size_t& offset, T value)
{
    for (size_t i = 0; i < sizeof(T); ++i)
    {
        dest[offset++] = static_cast<uint8_t>((value >> (i * 8)) & 0xFF);
    }
}

template <size_t N>
constexpr size_t sid_byte_size(const static_sid_t<N>&)
{
return 8 + 4 * N;
Copy link
Copy Markdown
Member

@dunhor dunhor Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment would be nice. Changing to use sizeof would be nicer. Moving this to be a static data member of static_sid_t would be nicest.

Comment on lines +750 to +758
// A typed ACE containing the ACE header fields and an embedded SID.
// Layout mirrors ACCESS_ALLOWED_ACE / ACCESS_DENIED_ACE (same binary layout).
template <size_t SubAuthorityCount>
struct static_ace_t
{
uint8_t AceType;
uint8_t AceFlags;
uint32_t Mask;
static_sid_t<SubAuthorityCount> Sid;
Copy link
Copy Markdown
Member

@dunhor dunhor Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both ACCESS_ALLOWED_ACE and ACCESS_DENIED_ACE are defined like:

typedef struct _ACCESS_*_ACE {
  ACE_HEADER  Header;
  ACCESS_MASK Mask;
  DWORD       SidStart;
} ACCESS_*_ACE;

and ACE_HEADER is defined as:

typedef struct _ACE_HEADER {
  BYTE AceType;
  BYTE AceFlags;
  WORD AceSize;
} ACE_HEADER;

This seems to be missing both AceSize and SidStart, so at least on first glance, this doesn't appear to be the same binary layout as those types. Am I missing something here?

Copy link
Copy Markdown
Member

@dunhor dunhor Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curiously the byte_size function/comment mention AceSize, however it doesn't make mention of SidStart. Ahh, okay, I neglected to read the purpose of SidStart - not relevant here

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see also that write_ace accounts for AceSize, so perhaps the comment is just misleading - this isn't the same binary layout?

Comment on lines +760 to +764
static constexpr size_t byte_size()
{
// ACE_HEADER (Type:1 + Flags:1 + Size:2) + Mask (4) + SID (8 + 4*N)
return 4 + 4 + 8 + 4 * SubAuthorityCount;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another that's probably better as a static data member

Comment on lines +861 to +872
bool seenAllow = false;
for (size_t i = 0; i < count; ++i)
{
if (types[i] == ACCESS_ALLOWED_ACE_TYPE)
{
seenAllow = true;
}
else if (types[i] == ACCESS_DENIED_ACE_TYPE && seenAllow)
{
return false; // deny after allow
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm weird, but two loops always seem clearer to me

size_t i = 0;
for (; i < count; ++i)
{
    if (types[i] == ACCESS_ALLOWED_ACE_TYPE)
    {
        ++i;
        break;
    }
}
for (; i < count; ++i)
{
    if (types[i] != ACCESS_ALLOWED_ACE_TYPE)
    {
        return false;
    }
}
return true;

Or if STL use is okay here

auto begin = types;
auto end = types + count;
return std::find(std::find(begin, end, ACCESS_ALLOWED_ACE_TYPE), end, ACCESS_DENIED_ACE_TYPE) == end;


PSECURITY_DESCRIPTOR get() const
{
return reinterpret_cast<PSECURITY_DESCRIPTOR>(const_cast<uint8_t*>(data));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get that the const_cast is because PSECURITY_DESCRIPTOR is PVOID and there's no PCSECURITY_DESCRIPTOR, but probably worth a comment

Comment on lines +1025 to +1026
template <typename OwnerSid, typename GroupSid, typename... Aces>
constexpr auto make_self_relative_sd(const OwnerSid& owner, const GroupSid& group, const Aces&... aces)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose of having type safe sd_owner_t and sd_group_t types if the inputs are just templates anyway and AFAICT there's no enforcement anywhere?

constexpr size_t acesSize = details::total_ace_size<Aces...>();
constexpr size_t totalSize = sdHeaderSize + ownerSize + groupSize + aclHeaderSize + acesSize;

// Deny ACEs must precede allow ACEs — will fail constexpr evaluation if violated.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm actually surprised WI_ASSERT is allowed in constexpr contexts. I actually had issues with this recently when compiling code that uses NT_ASSERT with Clang and IIRC WI_ASSERT is basically a re-packaging of NT_ASSERT.

But let's just assume that it's fine (since MSVC doesn't seem to complain about it) - this would only fail compilation when compiling for Debug, right?

Copy link
Copy Markdown
Member

@dunhor dunhor Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW throwing is the reliable way to fail compilation. You can wrap with if (std::is_constant_evaluated()) (C++20 - __cpp_lib_is_constant_evaluated >= 201811L && <type_traits>) or if consteval (C++23) to avoid the check at runtime if desired

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants