1use std::collections::HashMap;
20
21use crate::objects::common::{GPOChange, LdapObject, Link, Member, UserRight};
22use crate::objects::computer::Computer;
23use crate::objects::domain::Domain;
24use crate::objects::group::Group;
25use crate::objects::ou::Ou;
26use crate::objects::user::User;
27
28use super::sysvol::SysvolGpo;
29use super::types::{
30 GppGroupAction, GppMemberAction, RestrictedGroupDirective,
31 RestrictedGroupOperation,
32};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum TargetGroup {
39 LocalAdmins,
40 RemoteDesktopUsers,
41 DcomUsers,
42 PsRemote,
43}
44
45impl TargetGroup {
46 fn from_rid(rid: u32) -> Option<Self> {
47 match rid {
48 544 => Some(Self::LocalAdmins),
49 555 => Some(Self::RemoteDesktopUsers),
50 562 => Some(Self::DcomUsers),
51 580 => Some(Self::PsRemote),
52 _ => None,
53 }
54 }
55
56 fn from_group_name(name: &str) -> Option<Self> {
57 match name.trim().to_ascii_lowercase().as_str() {
58 "administrators" => Some(Self::LocalAdmins),
59 "remote desktop users" => Some(Self::RemoteDesktopUsers),
60 "distributed com users" => Some(Self::DcomUsers),
61 "remote management users" => Some(Self::PsRemote),
62 _ => None,
63 }
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct TypedPrincipal {
70 pub sid: String,
71 pub object_type: String,
72}
73
74pub trait Resolver {
78 fn resolve_name(&self, name: &str) -> Option<(String, String)>;
79 fn type_of_sid(&self, sid: &str) -> Option<String>;
80}
81
82pub struct ObjectResolver {
84 by_name: HashMap<String, (String, String)>, by_sid: HashMap<String, String>, }
87
88fn index_object(
89 sid: &str,
90 display: &str,
91 ty: &str,
92 by_name: &mut HashMap<String, (String, String)>,
93 by_sid: &mut HashMap<String, String>,
94) {
95 if sid.is_empty() {
96 return;
97 }
98 by_sid.insert(sid.to_uppercase(), ty.to_string());
99 let bare = display
100 .rsplit(['\\', '/'])
101 .next()
102 .unwrap_or(display)
103 .split('@')
104 .next()
105 .unwrap_or(display)
106 .trim()
107 .to_uppercase();
108 if !bare.is_empty() {
109 by_name
110 .entry(bare)
111 .or_insert_with(|| (sid.to_string(), ty.to_string()));
112 }
113}
114
115impl ObjectResolver {
116 pub fn build(users: &[User], groups: &[Group], computers: &[Computer]) -> Self {
117 let mut by_name = HashMap::new();
118 let mut by_sid = HashMap::new();
119
120 for u in users {
121 index_object(u.object_identifier(), u.properties().name(), "User", &mut by_name, &mut by_sid);
122 }
123 for g in groups {
124 index_object(g.object_identifier(), g.properties().name(), "Group", &mut by_name, &mut by_sid);
125 }
126 for c in computers {
127 let sid = c.object_identifier();
128 let name = c.properties().name();
129 index_object(sid, name, "Computer", &mut by_name, &mut by_sid);
130 let short = name.split('.').next().unwrap_or(name).to_uppercase();
131 if !short.is_empty() {
132 by_name
133 .entry(format!("{short}$"))
134 .or_insert_with(|| (sid.to_string(), "Computer".to_string()));
135 }
136 }
137
138 ObjectResolver { by_name, by_sid }
139 }
140}
141
142impl Resolver for ObjectResolver {
143 fn resolve_name(&self, name: &str) -> Option<(String, String)> {
144 self.by_name.get(&name.to_uppercase()).cloned()
145 }
146 fn type_of_sid(&self, sid: &str) -> Option<String> {
147 self.by_sid.get(&sid.to_uppercase()).cloned()
148 }
149}
150
151fn builtin_rid(s: &str) -> Option<u32> {
153 let up = s.trim().trim_start_matches('*').to_uppercase();
154 let rest = up.strip_prefix("S-1-5-32-")?;
155 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
156 digits.parse().ok()
157}
158
159fn resolve_principal(raw: &str, resolver: &impl Resolver, domain: &str) -> Option<TypedPrincipal> {
161 let p = raw.trim().trim_start_matches('*').trim();
162 if p.is_empty() {
163 return None;
164 }
165 if p.len() >= 4 && p[..4].eq_ignore_ascii_case("S-1-") {
166 let sid = p.to_uppercase();
167 let scoped = domain_scope_wellknown(&sid, domain);
172 let object_type = resolver.type_of_sid(&sid).unwrap_or_else(|| "Base".to_string());
173 return Some(TypedPrincipal { sid: scoped, object_type });
174 }
175 let bare = p.rsplit(['\\', '/']).next().unwrap_or(p);
176 resolver
177 .resolve_name(bare)
178 .map(|(sid, object_type)| TypedPrincipal { sid, object_type })
179}
180
181fn domain_scope_wellknown(sid: &str, domain: &str) -> String {
183 let is_wellknown = sid.starts_with("S-1-5-32-")
184 || (sid.starts_with("S-1-5-") && !sid.starts_with("S-1-5-21-"))
185 || sid.starts_with("S-1-1-")
186 || sid.starts_with("S-1-3-")
187 || sid.starts_with("S-1-0-");
188 if is_wellknown {
189 format!("{}-{}", domain.to_uppercase(), sid)
190 } else {
191 sid.to_string()
192 }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198enum Op {
199 Add,
200 Delete,
201 DeleteUsers,
202 DeleteGroups,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206enum Kind {
207 RestrictedMember,
208 RestrictedMemberOf,
209 LocalGroup,
210}
211
212#[derive(Debug, Clone)]
213struct Action {
214 group: TargetGroup,
215 kind: Kind,
216 op: Op,
217 principal: Option<TypedPrincipal>,
218}
219
220fn actions_for_gpo(gpo: &SysvolGpo, resolver: &impl Resolver, domain: &str) -> Vec<Action> {
222 let mut out = Vec::new();
223
224 for grp in &gpo.gpp_local_groups {
225 if grp.action() != GppGroupAction::Update {
226 continue;
227 }
228 let target = grp
229 .sid()
230 .and_then(builtin_rid)
231 .and_then(TargetGroup::from_rid)
232 .or_else(|| grp.name().and_then(TargetGroup::from_group_name));
233 let Some(group) = target else { continue };
234
235 if grp.delete_all_users() {
236 out.push(Action { group, kind: Kind::LocalGroup, op: Op::DeleteUsers, principal: None });
237 }
238 if grp.delete_all_groups() {
239 out.push(Action { group, kind: Kind::LocalGroup, op: Op::DeleteGroups, principal: None });
240 }
241 for m in grp.members() {
242 let op = match m.action() {
243 GppMemberAction::Add => Op::Add,
244 GppMemberAction::Remove => Op::Delete,
245 };
246 let principal = m
247 .sid()
248 .and_then(|s| resolve_principal(s, resolver, domain))
249 .or_else(|| m.name().and_then(|n| resolve_principal(n, resolver, domain)));
250 if let Some(p) = principal {
251 out.push(Action { group, kind: Kind::LocalGroup, op, principal: Some(p) });
252 }
253 }
254 }
255
256 for dir in &gpo.restricted_groups {
257 push_restricted(dir, resolver, domain, &mut out);
258 }
259
260 out
261}
262
263fn push_restricted(dir: &RestrictedGroupDirective, resolver: &impl Resolver, domain: &str, out: &mut Vec<Action>) {
264 match dir.operation() {
265 RestrictedGroupOperation::ReplaceMembers => {
266 let Some(group) = builtin_rid(dir.target()).and_then(TargetGroup::from_rid) else {
267 return;
268 };
269 for raw in dir.principals() {
270 if let Some(p) = resolve_principal(raw, resolver, domain) {
271 out.push(Action { group, kind: Kind::RestrictedMember, op: Op::Add, principal: Some(p) });
272 }
273 }
274 }
275 RestrictedGroupOperation::AddToParentGroups => {
276 let Some(member) = resolve_principal(dir.target(), resolver, domain) else {
277 return;
278 };
279 for raw in dir.principals() {
280 if let Some(group) = builtin_rid(raw).and_then(TargetGroup::from_rid) {
281 out.push(Action {
282 group,
283 kind: Kind::RestrictedMemberOf,
284 op: Op::Add,
285 principal: Some(member.clone()),
286 });
287 }
288 }
289 }
290 }
291}
292
293#[derive(Debug, Default)]
297pub struct MergedGroups {
298 pub local_admins: Vec<TypedPrincipal>,
299 pub remote_desktop_users: Vec<TypedPrincipal>,
300 pub dcom_users: Vec<TypedPrincipal>,
301 pub psremote_users: Vec<TypedPrincipal>,
302}
303
304fn dedup_by_sid(v: &mut Vec<TypedPrincipal>) {
305 let mut seen = std::collections::HashSet::new();
306 v.retain(|p| seen.insert(p.sid.clone()));
307}
308
309fn merge(actions: &[Action]) -> MergedGroups {
311 let mut merged = MergedGroups::default();
312 for group in [
313 TargetGroup::LocalAdmins,
314 TargetGroup::RemoteDesktopUsers,
315 TargetGroup::DcomUsers,
316 TargetGroup::PsRemote,
317 ] {
318 let mut restricted_member: Vec<TypedPrincipal> = Vec::new();
319 let mut restricted_memberof: Vec<TypedPrincipal> = Vec::new();
320 let mut local_groups: Vec<TypedPrincipal> = Vec::new();
321
322 for a in actions.iter().filter(|a| a.group == group) {
323 match (a.kind, a.op) {
324 (Kind::RestrictedMember, _) => {
325 if let Some(p) = &a.principal { restricted_member.push(p.clone()); }
326 }
327 (Kind::RestrictedMemberOf, _) => {
328 if let Some(p) = &a.principal { restricted_memberof.push(p.clone()); }
329 }
330 (Kind::LocalGroup, Op::Add) => {
331 if let Some(p) = &a.principal { local_groups.push(p.clone()); }
332 }
333 (Kind::LocalGroup, Op::Delete) => {
334 if let Some(p) = &a.principal { local_groups.retain(|x| x.sid != p.sid); }
335 }
336 (Kind::LocalGroup, Op::DeleteUsers) => {
337 local_groups.retain(|x| x.object_type != "User");
338 }
339 (Kind::LocalGroup, Op::DeleteGroups) => {
340 local_groups.retain(|x| x.object_type != "Group");
341 }
342 }
343 }
344
345 let mut final_set = restricted_memberof;
346 if restricted_member.is_empty() {
347 final_set.extend(local_groups);
348 } else {
349 final_set.extend(restricted_member);
350 }
351 dedup_by_sid(&mut final_set);
352
353 match group {
354 TargetGroup::LocalAdmins => merged.local_admins = final_set,
355 TargetGroup::RemoteDesktopUsers => merged.remote_desktop_users = final_set,
356 TargetGroup::DcomUsers => merged.dcom_users = final_set,
357 TargetGroup::PsRemote => merged.psremote_users = final_set,
358 }
359 }
360 merged
361}
362
363pub fn compute_merged(ordered_gpos: &[&SysvolGpo], resolver: &impl Resolver, domain: &str) -> MergedGroups {
366 let mut actions = Vec::new();
367 for gpo in ordered_gpos {
368 actions.extend(actions_for_gpo(gpo, resolver, domain));
369 }
370 merge(&actions)
371}
372
373pub fn resolve_privileges(
375 ordered_gpos: &[&SysvolGpo],
376 resolver: &impl Resolver,
377 domain: &str,
378) -> Vec<(String, Vec<TypedPrincipal>)> {
379 let mut order: Vec<String> = Vec::new();
380 let mut map: HashMap<String, Vec<TypedPrincipal>> = HashMap::new();
381
382 for gpo in ordered_gpos {
383 for pa in &gpo.privileges {
384 let key = pa.privilege().to_string();
385 if !map.contains_key(&key) {
386 order.push(key.clone());
387 map.insert(key.clone(), Vec::new());
388 }
389 let entry = map.get_mut(&key).unwrap();
390 for raw in pa.principals() {
391 if let Some(p) = resolve_principal(raw, resolver, domain) {
392 if !entry.iter().any(|x| x.sid == p.sid) {
393 entry.push(p);
394 }
395 }
396 }
397 }
398 }
399 order.into_iter().map(|k| { let v = map.remove(&k).unwrap(); (k, v) }).collect()
400}
401
402fn to_member(p: &TypedPrincipal) -> Member {
405 let mut m = Member::new();
406 *m.object_identifier_mut() = p.sid.clone();
407 *m.object_type_mut() = p.object_type.clone();
408 m
409}
410
411fn to_members(list: &[TypedPrincipal]) -> Vec<Member> {
412 list.iter().map(to_member).collect()
413}
414
415pub fn apply_gpo(
425 ous: &mut [Ou],
426 domains: &mut [Domain],
427 users: &[User],
428 groups: &[Group],
429 computers: &mut Vec<Computer>,
430 sysvol: &[SysvolGpo],
431 dn_sid: &HashMap<String, String>,
432 domain: &str,
433) {
434 let resolver = ObjectResolver::build(users, groups, computers);
435
436 let mut by_sid: HashMap<String, &SysvolGpo> = HashMap::new();
438 for g in sysvol {
439 let needle = g.guid.to_uppercase();
440 if let Some((_, sid)) = dn_sid.iter().find(|(dn, _)| dn.to_uppercase().contains(&needle)) {
441 by_sid.insert(sid.to_uppercase(), g);
442 }
443 }
444
445 log::debug!("[gpo] {} SYSVOL GPO(s) bridged to a SID via dn_sid", by_sid.len());
446 for (sid, g) in &by_sid {
447 log::debug!("[gpo] {} -> {}", g.guid, sid);
448 }
449
450 let mut priv_acc: HashMap<String, HashMap<String, Vec<TypedPrincipal>>> = HashMap::new();
452
453 for ou in ous.iter_mut() {
454 let label = ou.properties().distinguishedname().clone();
455 let links: Vec<Link> = ou.get_links().to_vec();
456 fill_container("OU", &label, &links, ou.gpo_changes_mut(), &by_sid, &resolver, domain, &mut priv_acc);
457 }
458 for dom in domains.iter_mut() {
459 let label = dom.properties().distinguishedname().clone();
460 let links: Vec<Link> = dom.get_links().to_vec();
461 fill_container("Domain", &label, &links, dom.gpo_changes_mut(), &by_sid, &resolver, domain, &mut priv_acc);
462 }
463
464 for c in computers.iter_mut() {
465 let Some(per) = priv_acc.get(c.object_identifier()) else { continue };
466 let ur = c.users_rights_mut();
467 for (privilege, members) in per {
468 let mut right = UserRight::new();
469 *right.privilege_mut() = privilege.clone();
470 *right.results_mut() = to_members(members);
471 *right.collected_mut() = true;
472 ur.push(right);
473 }
474 log::trace!("[gpo] Computer {}: {} UserRight(s) set", c.object_identifier(), per.len());
475 }
476
477}
478
479fn fill_container(
480 kind: &str,
481 label: &str,
482 links_src: &[Link],
483 changes: &mut GPOChange,
484 by_sid: &HashMap<String, &SysvolGpo>,
485 resolver: &impl Resolver,
486 domain: &str,
487 priv_acc: &mut HashMap<String, HashMap<String, Vec<TypedPrincipal>>>,
488) {
489 let mut links: Vec<(bool, String)> = links_src
491 .iter()
492 .map(|l| (*l.is_enforced(), l.guid().clone()))
493 .collect();
494 if links.is_empty() {
495 return;
496 }
497 links.sort_by_key(|(enforced, _)| *enforced);
498
499 let ordered: Vec<&SysvolGpo> = links
500 .iter()
501 .filter_map(|(_, sid)| by_sid.get(&sid.to_uppercase()).copied())
502 .collect();
503 if ordered.is_empty() {
504 return;
505 }
506
507 let merged = compute_merged(&ordered, resolver, domain);
509 *changes.local_admins_mut() = to_members(&merged.local_admins);
510 *changes.remote_desktop_users_mut() = to_members(&merged.remote_desktop_users);
511 *changes.dcom_users_mut() = to_members(&merged.dcom_users);
512 *changes.psremote_users_mut() = to_members(&merged.psremote_users);
513
514 log::trace!(
515 "[gpo] {kind} {label}: GPOChanges set from {} linked GPO(s) -> {} local admin(s), {} RDP, {} DCOM, {} PSRemote",
516 ordered.len(),
517 merged.local_admins.len(),
518 merged.remote_desktop_users.len(),
519 merged.dcom_users.len(),
520 merged.psremote_users.len(),
521 );
522
523
524 let privs = resolve_privileges(&ordered, resolver, domain);
526 if privs.is_empty() {
527 return;
528 }
529 log::debug!(
530 "[gpo] {kind} {label}: {} privilege(s) resolved, {} affected computer(s)",
531 privs.len(), changes.affected_computers().len()
532 );
533 for m in changes.affected_computers() {
534 let per = priv_acc.entry(m.object_identifier().clone()).or_default();
535 for (privilege, members) in &privs {
536 let e = per.entry(privilege.clone()).or_default();
537 for p in members {
538 if !e.iter().any(|x| x.sid == p.sid) {
539 e.push(p.clone());
540 }
541 }
542 }
543 }
544}
545
546#[cfg(test)]
549mod tests {
550 use super::*;
551
552 struct FakeResolver;
553 impl Resolver for FakeResolver {
554 fn resolve_name(&self, name: &str) -> Option<(String, String)> {
555 match name.to_uppercase().as_str() {
556 "ALICE" => Some(("S-1-5-21-1-1-1105".into(), "User".into())),
557 "HELPDESK" => Some(("S-1-5-21-1-1-1200".into(), "Group".into())),
558 _ => None,
559 }
560 }
561 fn type_of_sid(&self, sid: &str) -> Option<String> {
562 match sid {
563 "S-1-5-21-1-1-512" => Some("Group".into()),
564 _ => None,
565 }
566 }
567 }
568
569 fn gpo_with_restricted(target: &str, op: RestrictedGroupOperation, principals: &[&str]) -> SysvolGpo {
570 let mut g = SysvolGpo::default();
571 g.guid = "{G}".into();
572 g.restricted_groups = vec![RestrictedGroupDirective::new(
573 target,
574 op,
575 principals.iter().map(|s| s.to_string()).collect(),
576 )];
577 g
578 }
579
580 #[test]
581 fn replace_members_fills_local_admins() {
582 let g = gpo_with_restricted(
583 "S-1-5-32-544",
584 RestrictedGroupOperation::ReplaceMembers,
585 &["*S-1-5-21-1-1-512", "*ALICE"],
586 );
587 let domain= "ESSOS.LOCAL";
588 let merged = compute_merged(&[&g], &FakeResolver, domain);
589 let sids: Vec<&str> = merged.local_admins.iter().map(|p| p.sid.as_str()).collect();
590 assert_eq!(sids, vec!["S-1-5-21-1-1-512", "S-1-5-21-1-1-1105"]);
591 assert_eq!(merged.local_admins[0].object_type, "Group");
592 assert_eq!(merged.local_admins[1].object_type, "User");
593 assert!(merged.remote_desktop_users.is_empty());
594 }
595
596 #[test]
597 fn memberof_targets_the_parent_group_rid() {
598 let g = gpo_with_restricted(
599 "HELPDESK",
600 RestrictedGroupOperation::AddToParentGroups,
601 &["S-1-5-32-555"],
602 );
603 let domain= "ESSOS.LOCAL";
604 let merged = compute_merged(&[&g], &FakeResolver, domain);
605 assert_eq!(merged.remote_desktop_users.len(), 1);
606 assert_eq!(merged.remote_desktop_users[0].sid, "S-1-5-21-1-1-1200");
607 assert!(merged.local_admins.is_empty());
608 }
609
610 #[test]
611 fn restricted_member_overrides_local_groups() {
612 let g = gpo_with_restricted(
613 "S-1-5-32-544",
614 RestrictedGroupOperation::ReplaceMembers,
615 &["*ALICE"],
616 );
617 let domain= "ESSOS.LOCAL";
618 let merged = compute_merged(&[&g], &FakeResolver, domain);
619 assert_eq!(merged.local_admins.len(), 1);
620 assert_eq!(merged.local_admins[0].sid, "S-1-5-21-1-1-1105");
621 }
622
623 }