feat(bridge): read the whole permission store (perm.inventory), and sync group parents
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 9s
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 9s
The permission manager (PLAN_REDESIGNS section 1, D160): the site owns every permission and group on the server, so the bridge now reads all of it. - perm.inventory: every registered permission with the plugin that registered it (PermissionExists(name, plugin), public on both frameworks), every group with title, rank, parent and permissions, every holder's direct permissions and groups (not default), and the pairs event leases hold. Built a slice per tick, sent in pages under the 1 MiB line cap, paged from one snapshot. rg.inventory logs it. - perm.sync sets an existing group's title and rank and every group's parent; an absent title or rank means "leave it" (the adopt policy's hold). The managed list and the foreign scan are gone: the site reads the store and decides. Walked on both rigs: Oxide 85 permissions all owned, Carbon 104 with its 30 adminmodule.* unowned; ZoneManager owns ignoreflag.nokits. Refs RunicGateway/Module-Rust#21 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -319,6 +319,12 @@ namespace Oxide.Plugins
|
||||
_permJob = null;
|
||||
StopPermTimer();
|
||||
|
||||
// The same for an inventory being built: the site's request times out and it asks
|
||||
// again from page 0, which is the only way it ever reads one.
|
||||
_inventoryJob = null;
|
||||
_inventory = null;
|
||||
StopInventoryTimer();
|
||||
|
||||
// A config write waiting on a reload dies here too, and its files stay as written.
|
||||
// That is the honest outcome: the rollback exists to undo an edit that took a plugin
|
||||
// down, and this plugin being unloaded is not evidence of either — it is usually THIS
|
||||
@@ -955,6 +961,12 @@ namespace Oxide.Plugins
|
||||
HandlePermSync(cmd, reqId);
|
||||
break;
|
||||
|
||||
case "perm.inventory":
|
||||
// Protocol 13 (D160). The whole store, owners included, in pages — what the
|
||||
// site reads before every sync now that it owns all of it.
|
||||
HandlePermInventory(cmd, reqId);
|
||||
break;
|
||||
|
||||
case "config.list":
|
||||
// Protocol 5 (R18). What settings files exist on this host, and which plugins
|
||||
// are loaded to reload them. A description of the tree, never its contents.
|
||||
@@ -2996,9 +3008,11 @@ namespace Oxide.Plugins
|
||||
// log (PLAN.md §12.2 rule 1) — so without the pre-check a re-push into a server missing
|
||||
// one plugin is a silent partial. Registering the name here instead would fabricate a
|
||||
// permission the operator never installed (D33).
|
||||
// • A holder the site does not author is reported `foreign` and never touched (D31).
|
||||
// Somebody typing `oxide.grant` during an incident is drift, not an error, and the
|
||||
// website offers an operator two answers to it.
|
||||
// • A holder the site does not author is never touched by a sync. Until protocol 13 the
|
||||
// sync scanned the names the site claimed and reported such holders `foreign` (D31).
|
||||
// Now the site owns the whole store (D160) and reads it with `perm.inventory`, so it
|
||||
// finds a hand edit itself and answers it with the server's policy (D161): the sync
|
||||
// only ever carries what the site decided.
|
||||
// • A group member who has never connected is reported `pending`. The store has no user
|
||||
// record to put in a group yet (§12.2 rule 4), and the membership lands on their first
|
||||
// connection — a direct grant would have reached them immediately, and pretending the
|
||||
@@ -3042,8 +3056,12 @@ namespace Oxide.Plugins
|
||||
RetireMember,
|
||||
RetireGrant,
|
||||
RetireGroup,
|
||||
ScanPermission,
|
||||
ScanGroup,
|
||||
|
||||
/// <summary>
|
||||
/// Protocol 13: a group's parent (PLAN_REDESIGNS §1.4). Compiled after every group,
|
||||
/// so the parent it names already exists when it is applied.
|
||||
/// </summary>
|
||||
EnsureGroupParent,
|
||||
|
||||
/// <summary>Protocol 12: a group's BetterChat style (D138).</summary>
|
||||
EnsureChatStyle,
|
||||
@@ -3066,6 +3084,12 @@ namespace Oxide.Plugins
|
||||
public string Title;
|
||||
|
||||
public int Rank;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the site sent a rank. A group whose frame has none keeps the rank it has,
|
||||
/// rather than being set to 0 by an absent field.
|
||||
/// </summary>
|
||||
public bool HasRank;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3083,6 +3107,10 @@ namespace Oxide.Plugins
|
||||
public int Grants;
|
||||
public int Revokes;
|
||||
public int GroupsCreated;
|
||||
|
||||
/// <summary>Protocol 13: an existing group whose title, rank or parent the sync changed.</summary>
|
||||
public int GroupsUpdated;
|
||||
|
||||
public int GroupsRemoved;
|
||||
public int GroupPermissions;
|
||||
public int GroupPermissionsRemoved;
|
||||
@@ -3092,7 +3120,6 @@ namespace Oxide.Plugins
|
||||
public int Absent;
|
||||
|
||||
public readonly List<string> Unresolved = new List<string>();
|
||||
public readonly List<object> Foreign = new List<object>();
|
||||
public readonly List<string> Pending = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
@@ -3103,8 +3130,8 @@ namespace Oxide.Plugins
|
||||
public readonly List<string> NotLanded = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Group permissions an event lease holds (protocol 8). Neither applied to nor
|
||||
/// reported foreign; what the site asked for is kept on the hold instead.
|
||||
/// Group permissions an event lease holds (protocol 8). Not applied; what the site
|
||||
/// asked for is kept on the hold instead.
|
||||
/// </summary>
|
||||
public readonly List<object> Leased = new List<object>();
|
||||
|
||||
@@ -3113,17 +3140,6 @@ namespace Oxide.Plugins
|
||||
/// <summary>Permission names already reported unresolved, so each is reported once.</summary>
|
||||
public readonly HashSet<string> UnresolvedSeen = new HashSet<string>();
|
||||
|
||||
/// <summary>Who the site says should hold each managed permission, for the foreign scan.</summary>
|
||||
public readonly Dictionary<string, HashSet<string>> DesiredHolders =
|
||||
new Dictionary<string, HashSet<string>>();
|
||||
|
||||
/// <summary>Who the site says is in each managed group, and what it should carry.</summary>
|
||||
public readonly Dictionary<string, HashSet<string>> DesiredMembers =
|
||||
new Dictionary<string, HashSet<string>>();
|
||||
|
||||
public readonly Dictionary<string, HashSet<string>> DesiredGroupPermissions =
|
||||
new Dictionary<string, HashSet<string>>();
|
||||
|
||||
/// <summary>
|
||||
/// The site's kit credits for this server (protocol 10, D103), or null when the sync
|
||||
/// carried none — an older website, whose silence must not withdraw anybody's credit.
|
||||
@@ -3259,6 +3275,471 @@ namespace Oxide.Plugins
|
||||
EmitFrame(reply);
|
||||
}
|
||||
|
||||
// ---- protocol 13: the whole store (PLAN_REDESIGNS §1.2, D160) ----
|
||||
//
|
||||
// The site owns every permission and group on this server, so it has to be able to read
|
||||
// all of them — not just the names it once authored, which is all the foreign scan ever
|
||||
// looked at. One verb, `perm.inventory`, answers with:
|
||||
//
|
||||
// • every registered permission and the plugin that REGISTERED it. The owner comes from
|
||||
// `PermissionExists(name, plugin)` asked of each loaded plugin — public API on both
|
||||
// frameworks, proven on both rigs — never from the name's prefix, which is how
|
||||
// `zonemanager.ignoreflag.nokits` ends up filed under Kits in other tools.
|
||||
// • every group with its title, rank, parent and the permissions it carries itself.
|
||||
// • every Steam id that holds anything: its own permissions and its groups, except
|
||||
// `default`, which every connected player is in by the framework's own rule.
|
||||
// • the group permissions an event lease is holding, which are the lease's, not a hand
|
||||
// edit's.
|
||||
//
|
||||
// Neither framework can list its users, so the holders come from asking each permission
|
||||
// and each group who has it, and each of those walks every user. On a real store that is
|
||||
// permissions × users, so it is built a slice per tick, like the sync drain, and then
|
||||
// sent in pages that each fit the game link's 1 MiB line cap. The snapshot is taken once
|
||||
// and paged from memory, so a grant made between two pages cannot tear it.
|
||||
|
||||
/// <summary>How many permissions or groups one build step asks about.</summary>
|
||||
private const int InventorySourcesPerStep = 25;
|
||||
|
||||
/// <summary>How many bytes of rows one page may carry, well inside the 1 MiB line cap.</summary>
|
||||
private const int InventoryPageBudgetBytes = 700 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// How long a build may run before it is abandoned. The sidecar waits ten seconds for a
|
||||
/// reply; a build past this has already lost that wait, and a store that large needs a
|
||||
/// person to hear about it rather than a hitch every sync.
|
||||
/// </summary>
|
||||
private const long InventoryMaxBuildMs = 8000;
|
||||
|
||||
/// <summary>How long a finished snapshot is kept for the pages after the first.</summary>
|
||||
private const long InventoryKeepMs = 2 * 60 * 1000;
|
||||
|
||||
private sealed class InventoryUser
|
||||
{
|
||||
public string SteamId;
|
||||
public string Name;
|
||||
public readonly List<string> Permissions = new List<string>();
|
||||
public readonly List<string> Groups = new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>A build in flight. There is at most one; a second request is answered `busy`.</summary>
|
||||
private sealed class InventoryJob
|
||||
{
|
||||
public string ReqId;
|
||||
public long StartedMs;
|
||||
|
||||
/// <summary>Started from the server console (<c>rg.inventory</c>): logged, never sent.</summary>
|
||||
public bool FromConsole;
|
||||
|
||||
/// <summary>Permissions first, then groups prefixed with a NUL, one store question each.</summary>
|
||||
public readonly List<string> Sources = new List<string>();
|
||||
|
||||
public int Next;
|
||||
public readonly Dictionary<string, InventoryUser> Users = new Dictionary<string, InventoryUser>();
|
||||
public List<object> Permissions;
|
||||
public List<object> Groups;
|
||||
public List<object> Leased;
|
||||
}
|
||||
|
||||
/// <summary>A finished snapshot, paged from memory.</summary>
|
||||
private sealed class Inventory
|
||||
{
|
||||
public string Id;
|
||||
public long BuiltMs;
|
||||
public long BuildMs;
|
||||
public List<object> Permissions;
|
||||
public List<object> Groups;
|
||||
public List<object> Leased;
|
||||
public int UserCount;
|
||||
public readonly List<List<object>> Pages = new List<List<object>>();
|
||||
}
|
||||
|
||||
private InventoryJob _inventoryJob;
|
||||
private Timer _inventoryTimer;
|
||||
private Inventory _inventory;
|
||||
|
||||
/// <summary>The last inventory built, for <c>rg.perms</c>. Diagnostics only.</summary>
|
||||
private string _lastInventorySummary = "none yet";
|
||||
|
||||
/// <summary>
|
||||
/// The same build from the server console, logged rather than sent: how a rig proves the
|
||||
/// read — and times it on a large store — without the website or the sidecar's token.
|
||||
/// </summary>
|
||||
[ConsoleCommand("rg.inventory")]
|
||||
private void CmdInventory(ConsoleSystem.Arg arg)
|
||||
{
|
||||
if (arg.Connection != null)
|
||||
return;
|
||||
|
||||
if (_inventoryJob != null)
|
||||
{
|
||||
arg.ReplyWith("an inventory is already being built");
|
||||
return;
|
||||
}
|
||||
|
||||
StartInventory(null, true);
|
||||
arg.ReplyWith("inventory started; the summary is logged");
|
||||
}
|
||||
|
||||
/// <summary>Main thread. Page 0 starts a build; any other page is read from the snapshot it names.</summary>
|
||||
private void HandlePermInventory(Dictionary<string, object> cmd, string reqId)
|
||||
{
|
||||
string snapshotId = StringField(cmd, "snapshotId");
|
||||
int page = IntField(cmd, "page");
|
||||
|
||||
if (!string.IsNullOrEmpty(snapshotId))
|
||||
{
|
||||
Inventory held = _inventory;
|
||||
|
||||
if (held == null || held.Id != snapshotId || NowMs() - held.BuiltMs > InventoryKeepMs ||
|
||||
page < 0 || page >= held.Pages.Count)
|
||||
{
|
||||
// The site starts again from page 0; a stale page is never a partial answer.
|
||||
EmitPermError(reqId, "stale");
|
||||
return;
|
||||
}
|
||||
|
||||
EmitInventoryPage(held, page, reqId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_inventoryJob != null)
|
||||
{
|
||||
EmitPermError(reqId, "busy");
|
||||
return;
|
||||
}
|
||||
|
||||
StartInventory(reqId, false);
|
||||
}
|
||||
|
||||
/// <summary>Main thread. Reads the small parts at once and starts the sliced holder walk.</summary>
|
||||
private void StartInventory(string reqId, bool fromConsole)
|
||||
{
|
||||
var job = new InventoryJob { ReqId = reqId, StartedMs = NowMs(), FromConsole = fromConsole };
|
||||
|
||||
// Owners, groups and leases are small and read at once; the holder walk is sliced.
|
||||
var owner = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
string[] registered = permission.GetPermissions() ?? new string[0];
|
||||
|
||||
foreach (Plugin plugin in plugins.GetAll())
|
||||
{
|
||||
if (plugin == null)
|
||||
continue;
|
||||
|
||||
foreach (string perm in registered)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(perm) && !owner.ContainsKey(perm) && permission.PermissionExists(perm, plugin))
|
||||
owner[perm] = plugin.Name;
|
||||
}
|
||||
}
|
||||
|
||||
job.Permissions = new List<object>();
|
||||
|
||||
foreach (string perm in registered)
|
||||
{
|
||||
if (string.IsNullOrEmpty(perm))
|
||||
continue;
|
||||
|
||||
string lowered = Lower(perm);
|
||||
var row = new Dictionary<string, object> { { "name", lowered } };
|
||||
string by;
|
||||
|
||||
// A name no plugin owns — Carbon's built-in modules register theirs that way — is
|
||||
// sent without one, and the site groups it by prefix.
|
||||
if (owner.TryGetValue(perm, out by))
|
||||
row["owner"] = by;
|
||||
|
||||
job.Permissions.Add(row);
|
||||
job.Sources.Add(lowered);
|
||||
}
|
||||
|
||||
job.Groups = new List<object>();
|
||||
|
||||
foreach (string name in permission.GetGroups() ?? new string[0])
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
continue;
|
||||
|
||||
string lowered = Lower(name);
|
||||
var carried = new List<object>();
|
||||
|
||||
foreach (string perm in permission.GetGroupPermissions(name, false) ?? new string[0])
|
||||
{
|
||||
if (!string.IsNullOrEmpty(perm))
|
||||
carried.Add(Lower(perm));
|
||||
}
|
||||
|
||||
job.Groups.Add(new Dictionary<string, object>
|
||||
{
|
||||
{ "name", lowered },
|
||||
{ "title", permission.GetGroupTitle(name) ?? "" },
|
||||
{ "rank", permission.GetGroupRank(name) },
|
||||
{ "parent", Lower(permission.GetGroupParent(name)) ?? "" },
|
||||
{ "permissions", carried }
|
||||
});
|
||||
|
||||
// `default` holds every player who ever connected, by the framework's rule rather
|
||||
// than anybody's decision, so its members are not the site's to own.
|
||||
if (!string.Equals(lowered, "default", StringComparison.Ordinal))
|
||||
job.Sources.Add("\0" + lowered);
|
||||
}
|
||||
|
||||
job.Leased = new List<object>();
|
||||
|
||||
foreach (LeaseHold hold in _leases.Values)
|
||||
{
|
||||
if (hold.Key != GroupPermissionKey || string.IsNullOrEmpty(hold.Target))
|
||||
continue;
|
||||
|
||||
int slash = hold.Target.IndexOf('/');
|
||||
|
||||
if (slash <= 0)
|
||||
continue;
|
||||
|
||||
job.Leased.Add(new Dictionary<string, object>
|
||||
{
|
||||
{ "kind", "group-permission" },
|
||||
{ "subject", Lower(hold.Target.Substring(0, slash)) },
|
||||
{ "object", Lower(hold.Target.Substring(slash + 1)) }
|
||||
});
|
||||
}
|
||||
|
||||
_inventoryJob = job;
|
||||
_inventoryTimer = timer.Every(PermStepSeconds, StepInventory);
|
||||
|
||||
// A small store — every rig, and most real servers — finishes inside this call.
|
||||
StepInventory();
|
||||
}
|
||||
|
||||
/// <summary>Main thread. Asks up to <see cref="InventorySourcesPerStep"/> permissions or groups who holds them.</summary>
|
||||
private void StepInventory()
|
||||
{
|
||||
InventoryJob job = _inventoryJob;
|
||||
|
||||
if (job == null)
|
||||
{
|
||||
StopInventoryTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (NowMs() - job.StartedMs > InventoryMaxBuildMs)
|
||||
{
|
||||
_inventoryJob = null;
|
||||
StopInventoryTimer();
|
||||
Puts($"permission inventory abandoned after {InventoryMaxBuildMs} ms at {job.Next} of {job.Sources.Count} sources, {job.Users.Count} users");
|
||||
|
||||
if (!job.FromConsole)
|
||||
EmitPermError(job.ReqId, "too-large");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
int budget = InventorySourcesPerStep;
|
||||
|
||||
while (budget-- > 0 && job.Next < job.Sources.Count)
|
||||
{
|
||||
string source = job.Sources[job.Next++];
|
||||
|
||||
try
|
||||
{
|
||||
bool isGroup = source.Length > 0 && source[0] == '\0';
|
||||
string name = isGroup ? source.Substring(1) : source;
|
||||
string[] entries = isGroup ? permission.GetUsersInGroup(name) : permission.GetPermissionUsers(name);
|
||||
|
||||
if (entries == null)
|
||||
continue;
|
||||
|
||||
foreach (string entry in entries)
|
||||
{
|
||||
string id = UserId(entry);
|
||||
|
||||
if (string.IsNullOrEmpty(id))
|
||||
continue;
|
||||
|
||||
InventoryUser user;
|
||||
|
||||
if (!job.Users.TryGetValue(id, out user))
|
||||
{
|
||||
user = new InventoryUser { SteamId = id, Name = StoredName(entry) };
|
||||
job.Users[id] = user;
|
||||
}
|
||||
|
||||
if (isGroup)
|
||||
user.Groups.Add(name);
|
||||
else
|
||||
user.Permissions.Add(name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// One odd name must not lose the whole read; the next sync reads it again.
|
||||
Puts($"permission inventory: '{source.TrimStart('\0')}' threw: {Flatten(ex.Message)}");
|
||||
}
|
||||
}
|
||||
|
||||
if (job.Next < job.Sources.Count)
|
||||
return;
|
||||
|
||||
_inventoryJob = null;
|
||||
StopInventoryTimer();
|
||||
|
||||
Inventory done = Paginate(job);
|
||||
_inventory = done;
|
||||
_lastInventorySummary = $"{done.Id}: {done.Permissions.Count} permissions, {done.Groups.Count} groups, " +
|
||||
$"{done.UserCount} users, {done.Pages.Count} pages, {done.BuildMs} ms at={DateTime.UtcNow:HH:mm:ss}Z";
|
||||
|
||||
if (job.FromConsole)
|
||||
{
|
||||
// To the log, where a rig walk can read it: the console's own reply never reaches
|
||||
// Oxide's log file.
|
||||
Puts($"inventory {_lastInventorySummary}");
|
||||
|
||||
var owners = new Dictionary<string, int>();
|
||||
|
||||
foreach (Dictionary<string, object> row in done.Permissions)
|
||||
{
|
||||
object by;
|
||||
string key = row.TryGetValue("owner", out by) ? (string)by : "(none)";
|
||||
int n;
|
||||
owners.TryGetValue(key, out n);
|
||||
owners[key] = n + 1;
|
||||
}
|
||||
|
||||
var ownerText = new List<string>();
|
||||
|
||||
foreach (KeyValuePair<string, int> kv in owners)
|
||||
ownerText.Add(kv.Key + "=" + kv.Value);
|
||||
|
||||
List<object> sample = done.Pages[0].GetRange(0, Math.Min(10, done.Pages[0].Count));
|
||||
|
||||
Puts("inventory owners: " + string.Join(", ", ownerText.ToArray()));
|
||||
Puts("inventory groups: " + JsonConvert.SerializeObject(done.Groups, Formatting.None));
|
||||
Puts("inventory leased: " + JsonConvert.SerializeObject(done.Leased, Formatting.None));
|
||||
Puts("inventory users: " + JsonConvert.SerializeObject(sample, Formatting.None));
|
||||
return;
|
||||
}
|
||||
|
||||
EmitInventoryPage(done, 0, job.ReqId);
|
||||
}
|
||||
|
||||
private void StopInventoryTimer()
|
||||
{
|
||||
if (_inventoryTimer == null)
|
||||
return;
|
||||
|
||||
_inventoryTimer.Destroy();
|
||||
_inventoryTimer = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits the users into pages by their serialised size. Page 0 also carries the
|
||||
/// permissions, groups and leases, so its budget is what they leave.
|
||||
/// </summary>
|
||||
private Inventory Paginate(InventoryJob job)
|
||||
{
|
||||
var inv = new Inventory
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N").Substring(0, 12),
|
||||
BuiltMs = NowMs(),
|
||||
BuildMs = NowMs() - job.StartedMs,
|
||||
Permissions = job.Permissions,
|
||||
Groups = job.Groups,
|
||||
Leased = job.Leased,
|
||||
UserCount = job.Users.Count
|
||||
};
|
||||
|
||||
int header = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(new Dictionary<string, object>
|
||||
{
|
||||
{ "permissions", job.Permissions },
|
||||
{ "groups", job.Groups },
|
||||
{ "leased", job.Leased }
|
||||
}, Formatting.None));
|
||||
|
||||
var ids = new List<string>(job.Users.Keys);
|
||||
ids.Sort(StringComparer.Ordinal);
|
||||
|
||||
var current = new List<object>();
|
||||
int bytes = header;
|
||||
|
||||
foreach (string id in ids)
|
||||
{
|
||||
InventoryUser u = job.Users[id];
|
||||
var row = new Dictionary<string, object>
|
||||
{
|
||||
{ "steamId", u.SteamId },
|
||||
{ "permissions", u.Permissions },
|
||||
{ "groups", u.Groups }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(u.Name))
|
||||
row["name"] = u.Name;
|
||||
|
||||
int size = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(row, Formatting.None)) + 1;
|
||||
|
||||
if (current.Count > 0 && bytes + size > InventoryPageBudgetBytes)
|
||||
{
|
||||
inv.Pages.Add(current);
|
||||
current = new List<object>();
|
||||
bytes = 0;
|
||||
}
|
||||
|
||||
current.Add(row);
|
||||
bytes += size;
|
||||
}
|
||||
|
||||
// Always at least one page, so an empty store still answers page 0.
|
||||
inv.Pages.Add(current);
|
||||
return inv;
|
||||
}
|
||||
|
||||
private void EmitInventoryPage(Inventory inv, int page, string reqId)
|
||||
{
|
||||
Dictionary<string, object> reply = Frame("perm.inventory", "reply");
|
||||
reply["snapshotId"] = inv.Id;
|
||||
reply["page"] = page;
|
||||
reply["pages"] = inv.Pages.Count;
|
||||
reply["more"] = page + 1 < inv.Pages.Count;
|
||||
reply["users"] = inv.Pages[page];
|
||||
|
||||
if (page == 0)
|
||||
{
|
||||
reply["permissions"] = inv.Permissions;
|
||||
reply["groups"] = inv.Groups;
|
||||
reply["leased"] = inv.Leased;
|
||||
reply["stats"] = new Dictionary<string, object>
|
||||
{
|
||||
{ "permissions", inv.Permissions.Count },
|
||||
{ "groups", inv.Groups.Count },
|
||||
{ "users", inv.UserCount },
|
||||
{ "buildMs", inv.BuildMs }
|
||||
};
|
||||
}
|
||||
|
||||
if (reqId != null)
|
||||
reply["reqId"] = reqId;
|
||||
|
||||
EmitFrame(reply);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The name out of a store listing entry — <c>76561198…(Wanderer)</c> or
|
||||
/// <c>76561198… (Wanderer)</c> — or null. Between the FIRST bracket and the LAST, so a
|
||||
/// name with brackets of its own survives.
|
||||
/// </summary>
|
||||
private static string StoredName(string entry)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry))
|
||||
return null;
|
||||
|
||||
int open = entry.IndexOf('(');
|
||||
int close = entry.LastIndexOf(')');
|
||||
|
||||
if (open < 0 || close <= open + 1)
|
||||
return null;
|
||||
|
||||
string name = entry.Substring(open + 1, close - open - 1).Trim();
|
||||
return name.Length > 0 && name != "Unnamed" ? name : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main thread. Compiles a desired set into operations and starts draining it.
|
||||
///
|
||||
@@ -3291,21 +3772,39 @@ namespace Oxide.Plugins
|
||||
// Order matters and it is not cosmetic: a group has to exist before anything can be
|
||||
// put in it, and a permission has to be on the group before a member inherits it.
|
||||
|
||||
// Protocol 13: parents are applied after every group exists, because a group may name
|
||||
// a parent that comes later in the frame.
|
||||
var parentOps = new List<PermOp>();
|
||||
|
||||
foreach (Dictionary<string, object> group in Rows(cmd, "groups"))
|
||||
{
|
||||
string name = Lower(StringField(group, "name"));
|
||||
if (string.IsNullOrEmpty(name))
|
||||
continue;
|
||||
|
||||
// Protocol 13: an absent title or rank means "leave it" — the site holds an
|
||||
// in-game change to them while a person decides (D161's `adopt`) — and an
|
||||
// empty title is a title, sent verbatim, not a reason to use the name.
|
||||
job.Ops.Add(new PermOp
|
||||
{
|
||||
Kind = PermOpKind.EnsureGroup,
|
||||
Subject = name,
|
||||
Title = StringField(group, "title") ?? name,
|
||||
Rank = IntField(group, "rank")
|
||||
Title = group.ContainsKey("title") ? (StringField(group, "title") ?? "") : null,
|
||||
Rank = IntField(group, "rank"),
|
||||
HasRank = group.ContainsKey("rank")
|
||||
});
|
||||
|
||||
HashSet<string> wanted = Bucket(job.DesiredGroupPermissions, name);
|
||||
// Only a frame that says something about the parent changes it: "" clears it, and
|
||||
// an absent key leaves whatever the group has.
|
||||
if (group.ContainsKey("parent"))
|
||||
{
|
||||
parentOps.Add(new PermOp
|
||||
{
|
||||
Kind = PermOpKind.EnsureGroupParent,
|
||||
Subject = name,
|
||||
Object = Lower(StringField(group, "parent")) ?? ""
|
||||
});
|
||||
}
|
||||
|
||||
foreach (string perm in Strings(group, "permissions"))
|
||||
{
|
||||
@@ -3313,7 +3812,6 @@ namespace Oxide.Plugins
|
||||
if (string.IsNullOrEmpty(lowered))
|
||||
continue;
|
||||
|
||||
wanted.Add(lowered);
|
||||
job.Ops.Add(new PermOp
|
||||
{
|
||||
Kind = PermOpKind.EnsureGroupPermission,
|
||||
@@ -3327,7 +3825,6 @@ namespace Oxide.Plugins
|
||||
if (string.IsNullOrEmpty(member))
|
||||
continue;
|
||||
|
||||
Bucket(job.DesiredMembers, name).Add(member);
|
||||
job.Ops.Add(new PermOp
|
||||
{
|
||||
Kind = PermOpKind.EnsureMember,
|
||||
@@ -3336,10 +3833,6 @@ namespace Oxide.Plugins
|
||||
});
|
||||
}
|
||||
|
||||
// A group with no members still has to be scanned: the interesting case is a
|
||||
// member the site did not put there.
|
||||
Bucket(job.DesiredMembers, name);
|
||||
|
||||
// Protocol 12: the group's BetterChat style. After the group's own ops, so the
|
||||
// Oxide group BetterChat's members are read from exists first. With BetterChat
|
||||
// absent nothing is compiled, and the report says why (§33.2).
|
||||
@@ -3367,7 +3860,6 @@ namespace Oxide.Plugins
|
||||
if (string.IsNullOrEmpty(lowered))
|
||||
continue;
|
||||
|
||||
Bucket(job.DesiredHolders, lowered).Add(steamId);
|
||||
job.Ops.Add(new PermOp
|
||||
{
|
||||
Kind = PermOpKind.EnsureGrant,
|
||||
@@ -3377,6 +3869,10 @@ namespace Oxide.Plugins
|
||||
}
|
||||
}
|
||||
|
||||
// Parents after every group exists and before any retirement, so a group the site is
|
||||
// about to remove is no longer anybody's parent when it goes.
|
||||
job.Ops.AddRange(parentOps);
|
||||
|
||||
// ── What the site has withdrawn ────────────────────────────────────
|
||||
//
|
||||
// The site remembers what it put here, so a grant it has since removed arrives as an
|
||||
@@ -3416,24 +3912,8 @@ namespace Oxide.Plugins
|
||||
}
|
||||
}
|
||||
|
||||
// ── The namespace the site claims ──────────────────────────────────
|
||||
//
|
||||
// Foreign holders can only be found for names the site says it manages. A permission it
|
||||
// does not claim is somebody else's business entirely and is never enumerated — which
|
||||
// also bounds this scan by the site's own set rather than by the size of the store.
|
||||
|
||||
foreach (string perm in Strings(cmd, "managed"))
|
||||
{
|
||||
string lowered = Lower(perm);
|
||||
if (string.IsNullOrEmpty(lowered))
|
||||
continue;
|
||||
|
||||
Bucket(job.DesiredHolders, lowered);
|
||||
job.Ops.Add(new PermOp { Kind = PermOpKind.ScanPermission, Subject = lowered });
|
||||
}
|
||||
|
||||
foreach (string group in job.DesiredMembers.Keys)
|
||||
job.Ops.Add(new PermOp { Kind = PermOpKind.ScanGroup, Subject = group });
|
||||
// Protocol 13 dropped the `managed` list and the foreign scan it drove: the site reads
|
||||
// the whole store with `perm.inventory` and decides for itself (D160, D161).
|
||||
|
||||
// ── The kit credits (protocol 10) ──────────────────────────────────
|
||||
//
|
||||
@@ -3550,15 +4030,54 @@ namespace Oxide.Plugins
|
||||
switch (op.Kind)
|
||||
{
|
||||
case PermOpKind.EnsureGroup:
|
||||
if (permission.GroupExists(op.Subject))
|
||||
if (!permission.GroupExists(op.Subject))
|
||||
{
|
||||
permission.CreateGroup(op.Subject, op.Title ?? op.Subject, op.Rank);
|
||||
job.GroupsCreated++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Protocol 13: the site owns an existing group's title and rank too (D160), so
|
||||
// a difference is written rather than left. Until now a group's title was only
|
||||
// ever set when the sync created it.
|
||||
bool updated = false;
|
||||
|
||||
if (op.Title != null && !string.Equals(permission.GetGroupTitle(op.Subject) ?? "", op.Title, StringComparison.Ordinal))
|
||||
{
|
||||
permission.SetGroupTitle(op.Subject, op.Title);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (op.HasRank && permission.GetGroupRank(op.Subject) != op.Rank)
|
||||
{
|
||||
permission.SetGroupRank(op.Subject, op.Rank);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (updated)
|
||||
job.GroupsUpdated++;
|
||||
else
|
||||
job.AlreadyCorrect++;
|
||||
|
||||
break;
|
||||
|
||||
case PermOpKind.EnsureGroupParent:
|
||||
string currentParent = Lower(permission.GetGroupParent(op.Subject)) ?? "";
|
||||
|
||||
if (string.Equals(currentParent, op.Object, StringComparison.Ordinal))
|
||||
{
|
||||
job.AlreadyCorrect++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Both frameworks refuse a parent that does not exist or that would make a
|
||||
// loop, and say so only by returning false. The read-back is what counts.
|
||||
permission.SetGroupParent(op.Subject, string.IsNullOrEmpty(op.Object) ? null : op.Object);
|
||||
|
||||
if (string.Equals(Lower(permission.GetGroupParent(op.Subject)) ?? "", op.Object, StringComparison.Ordinal))
|
||||
job.GroupsUpdated++;
|
||||
else
|
||||
{
|
||||
permission.CreateGroup(op.Subject, op.Title, op.Rank);
|
||||
job.GroupsCreated++;
|
||||
}
|
||||
job.NotLanded.Add(op.Subject + ":parent:" + op.Object);
|
||||
|
||||
break;
|
||||
|
||||
@@ -3685,14 +4204,6 @@ namespace Oxide.Plugins
|
||||
|
||||
break;
|
||||
|
||||
case PermOpKind.ScanPermission:
|
||||
ScanPermission(job, op.Subject);
|
||||
break;
|
||||
|
||||
case PermOpKind.ScanGroup:
|
||||
ScanGroup(job, op.Subject);
|
||||
break;
|
||||
|
||||
case PermOpKind.EnsureChatStyle:
|
||||
ApplyChatStyle(job, op.Subject);
|
||||
break;
|
||||
@@ -3703,105 +4214,6 @@ namespace Oxide.Plugins
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everybody holding a managed permission DIRECTLY, minus everybody the site says
|
||||
/// should — the rest is a hand edit, and it is reported rather than undone.
|
||||
/// </summary>
|
||||
private void ScanPermission(PermSyncJob job, string perm)
|
||||
{
|
||||
if (!permission.PermissionExists(perm))
|
||||
return;
|
||||
|
||||
HashSet<string> desired;
|
||||
if (!job.DesiredHolders.TryGetValue(perm, out desired))
|
||||
desired = new HashSet<string>();
|
||||
|
||||
string[] holders = permission.GetPermissionUsers(perm);
|
||||
|
||||
if (holders == null)
|
||||
return;
|
||||
|
||||
foreach (string entry in holders)
|
||||
{
|
||||
string id = UserId(entry);
|
||||
|
||||
if (string.IsNullOrEmpty(id) || desired.Contains(id))
|
||||
continue;
|
||||
|
||||
job.Foreign.Add(new Dictionary<string, object>
|
||||
{
|
||||
{ "kind", "grant" },
|
||||
{ "subject", id },
|
||||
{ "object", perm }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same question about a managed group, asked twice: who is in it that the site did
|
||||
/// not put there, and what does it carry that the site did not put on it.
|
||||
/// </summary>
|
||||
private void ScanGroup(PermSyncJob job, string group)
|
||||
{
|
||||
if (!permission.GroupExists(group))
|
||||
return;
|
||||
|
||||
HashSet<string> members;
|
||||
if (!job.DesiredMembers.TryGetValue(group, out members))
|
||||
members = new HashSet<string>();
|
||||
|
||||
string[] present = permission.GetUsersInGroup(group);
|
||||
|
||||
if (present != null)
|
||||
{
|
||||
foreach (string entry in present)
|
||||
{
|
||||
string id = UserId(entry);
|
||||
|
||||
if (string.IsNullOrEmpty(id) || members.Contains(id))
|
||||
continue;
|
||||
|
||||
job.Foreign.Add(new Dictionary<string, object>
|
||||
{
|
||||
{ "kind", "member" },
|
||||
{ "subject", id },
|
||||
{ "object", group }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
HashSet<string> wanted;
|
||||
if (!job.DesiredGroupPermissions.TryGetValue(group, out wanted))
|
||||
wanted = new HashSet<string>();
|
||||
|
||||
string[] carried = permission.GetGroupPermissions(group, false);
|
||||
|
||||
if (carried == null)
|
||||
return;
|
||||
|
||||
foreach (string perm in carried)
|
||||
{
|
||||
string lowered = Lower(perm);
|
||||
|
||||
if (string.IsNullOrEmpty(lowered) || wanted.Contains(lowered))
|
||||
continue;
|
||||
|
||||
// A pair an event has borrowed is not somebody's console grant (§27.3).
|
||||
if (LeasedGroupPermission(group, lowered) != null)
|
||||
{
|
||||
NoteLeased(job, group, lowered);
|
||||
continue;
|
||||
}
|
||||
|
||||
job.Foreign.Add(new Dictionary<string, object>
|
||||
{
|
||||
{ "kind", "group-permission" },
|
||||
{ "subject", group },
|
||||
{ "object", lowered }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sync op about a pair an event lease holds. The store is left to the lease; what
|
||||
/// the site wants is recorded on the hold and becomes the value the lease gives back
|
||||
@@ -3919,6 +4331,7 @@ namespace Oxide.Plugins
|
||||
{ "grants", job.Grants },
|
||||
{ "revokes", job.Revokes },
|
||||
{ "groupsCreated", job.GroupsCreated },
|
||||
{ "groupsUpdated", job.GroupsUpdated },
|
||||
{ "groupsRemoved", job.GroupsRemoved },
|
||||
{ "groupPermissions", job.GroupPermissions },
|
||||
{ "groupPermissionsRemoved", job.GroupPermissionsRemoved },
|
||||
@@ -3931,7 +4344,6 @@ namespace Oxide.Plugins
|
||||
report["alreadyCorrect"] = job.AlreadyCorrect;
|
||||
report["absent"] = job.Absent;
|
||||
report["unresolved"] = job.Unresolved;
|
||||
report["foreign"] = job.Foreign;
|
||||
report["leased"] = job.Leased;
|
||||
report["pending"] = job.Pending;
|
||||
report["notLanded"] = job.NotLanded;
|
||||
@@ -3972,8 +4384,8 @@ namespace Oxide.Plugins
|
||||
|
||||
_lastPermSummary =
|
||||
$"ops={job.Ops.Count} grants={job.Grants} revokes={job.Revokes} " +
|
||||
$"groups={job.GroupsCreated} members={job.Members} same={job.AlreadyCorrect} " +
|
||||
$"unresolved={job.Unresolved.Count} foreign={job.Foreign.Count} " +
|
||||
$"groups={job.GroupsCreated}+{job.GroupsUpdated} members={job.Members} same={job.AlreadyCorrect} " +
|
||||
$"unresolved={job.Unresolved.Count} " +
|
||||
$"pending={job.Pending.Count} notLanded={job.NotLanded.Count} " +
|
||||
$"chat={(job.ChatLoaded ? $"applied {job.ChatApplied} saved {job.ChatSaved} drift {job.ChatDrift.Count} removed {job.ChatRemoved.Count} failed {job.ChatFailed.Count}" : "absent")} " +
|
||||
$"at={DateTime.UtcNow:HH:mm:ss}Z";
|
||||
@@ -3996,8 +4408,8 @@ namespace Oxide.Plugins
|
||||
// ---- drift, as it happens ----
|
||||
//
|
||||
// Both frameworks raise a hook for every permission write, ours included. They are NOT the
|
||||
// drift record — the sync report is, because only the site's desired set can say whether a
|
||||
// grant is foreign. What they are is a reason to reconcile SOON: an operator typing
|
||||
// drift record — the inventory is (protocol 13), because only the site's own record can say
|
||||
// whether a grant is a hand edit. What they are is a reason to reconcile SOON: an operator typing
|
||||
// `oxide.grant` at the console shows up on the website in seconds instead of at the next
|
||||
// scheduled audit, and the authoritative answer still comes from a sync.
|
||||
//
|
||||
@@ -6893,8 +7305,8 @@ namespace Oxide.Plugins
|
||||
|
||||
/// <summary>
|
||||
/// Whether a lease holds this group permission, for the permission mirror. A pair a
|
||||
/// lease has borrowed is the lease's until it ends: a sync neither applies to it nor
|
||||
/// reports it foreign.
|
||||
/// lease has borrowed is the lease's until it ends: a sync does not apply to it, and the
|
||||
/// inventory lists it under <c>leased</c> so the site never takes it for a hand edit.
|
||||
/// </summary>
|
||||
private LeaseHold LeasedGroupPermission(string group, string perm)
|
||||
{
|
||||
@@ -11045,19 +11457,6 @@ namespace Oxide.Plugins
|
||||
return value == null ? null : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static HashSet<string> Bucket(Dictionary<string, HashSet<string>> map, string key)
|
||||
{
|
||||
HashSet<string> bucket;
|
||||
|
||||
if (!map.TryGetValue(key, out bucket))
|
||||
{
|
||||
bucket = new HashSet<string>();
|
||||
map[key] = bucket;
|
||||
}
|
||||
|
||||
return bucket;
|
||||
}
|
||||
|
||||
// ---- diagnostics ----
|
||||
|
||||
/// <summary>
|
||||
@@ -11097,8 +11496,9 @@ namespace Oxide.Plugins
|
||||
arg.ReplyWith(
|
||||
$"protocol={ProtocolVersion} registered={(registered != null ? registered.Length : 0)} " +
|
||||
$"groups={(groups != null ? groups.Length : 0)} " +
|
||||
$"running={(_permJob != null)}" + Environment.NewLine +
|
||||
$"last sync: {_lastPermSummary}");
|
||||
$"running={(_permJob != null)} inventoryBuilding={(_inventoryJob != null)}" + Environment.NewLine +
|
||||
$"last sync: {_lastPermSummary}" + Environment.NewLine +
|
||||
$"last inventory: {_lastInventorySummary}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user