diff --git a/overlay.toml b/overlay.toml index 97adc01..e80c61a 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,8 +26,9 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 2 — the transport plus the read path (docs/rust-link/PROTOCOL.md §8). -protocol = 3 +# Current: 4 — the transport, the read path, identity, and the permission mirror +# (docs/rust-link/PROTOCOL.md §10). +protocol = 4 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 520dcc2..c32cdf7 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -56,7 +56,7 @@ namespace Oxide.Plugins /// overlay's overlay.toml carries this same number so the installer can refuse /// to pair a sidecar and an overlay that disagree. /// - private const int ProtocolVersion = 3; + private const int ProtocolVersion = 4; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -231,7 +231,10 @@ namespace Oxide.Plugins "OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned", "OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath", "OnPlayerReported", "OnUserBanned", "OnUserUnbanned", - "CanUserLogin", "OnUserApproved" + "CanUserLogin", "OnUserApproved", + "OnUserPermissionGranted", "OnUserPermissionRevoked", + "OnUserGroupAdded", "OnUserGroupRemoved", + "OnGroupPermissionGranted", "OnGroupPermissionRevoked" }; private void Init() @@ -286,6 +289,13 @@ namespace Oxide.Plugins _cadence = null; } + // A half-drained permission sync is abandoned rather than finished, and the site is + // told nothing — which is right: the report would describe a set that was only partly + // applied, and the next sync re-derives the whole answer from the store anyway. What + // must not happen is the timer outliving the plugin and writing during a reload. + _permJob = null; + StopPermTimer(); + _running = false; _stopping.Set(); Wake.Set(); @@ -754,6 +764,19 @@ namespace Oxide.Plugins HandleLinkConfirm(cmd, reqId); break; + case "perm.catalogue": + // Protocol 4. What this server's loaded plugins have registered — the option + // source the website's authoring form offers, so a grant can only be written + // against a name that will actually resolve. + HandlePermCatalogue(reqId); + break; + + case "perm.sync": + // The first command that WRITES to the game (protocol 4, R2). Everything + // before it read, or spent a code the game itself had minted. + HandlePermSync(cmd, reqId); + break; + default: Puts($"unknown command '{verb}'"); break; @@ -1996,6 +2019,981 @@ namespace Oxide.Plugins return Guid.NewGuid().ToString("N").Substring(0, CodeLength).ToUpperInvariant(); } + // ---- site-owned permissions (protocol 4) ---- + // + // R2: the website is the author of record for groups and grants, and this framework's own + // permission store is an ENFORCEMENT CACHE. Every third-party plugin honours a site grant + // with no adapter, because they all already call `UserHasPermission`. + // + // ── One verb, and the plugin does the diffing (D32) ─────────────────────── + // + // The site sends the whole set it authors for this server; this plugin compares it against + // the live store and writes only what differs. It is the same shape as a board, pointed the + // other way: one producer, re-sent on connect, reconciled by the receiver. + // + // The alternative — shipping this server's permission store to the website and diffing + // there — was rejected for two reasons. The store is the bigger of the two sets and would + // cross the wire constantly, and a website holding a copy of it has a second source of + // truth that is stale the moment it lands. + // + // ── The site must not be able to stall the game, either ─────────────────── + // + // This is the first command whose work is UNBOUNDED: a community with two thousand linked + // players has thousands of grants in one frame, and applying them in the tick the frame + // arrives is a freeze the operator will blame on the game. So a sync is compiled into a + // list of single-store operations and drained a few hundred at a time on a timer, and the + // report goes back when the last one lands. + // + // That is the same rule the outbound queue lives under (§5), applied to the inbound half: + // **nothing the far side sends may cost the main thread an unbounded amount of work.** + // + // ── What it reports, and what it will not do ────────────────────────────── + // + // • A permission this server has not REGISTERED is reported `unresolved` and skipped. + // `GrantUserPermission` silently no-ops for an unregistered name — void, no throw, no + // 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 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 + // two are the same thing is how a site promises access it did not give. + + /// + /// How many store operations one timer step performs. + /// + /// + /// Each is a dictionary lookup and, at most, one write to an in-memory structure the + /// framework flushes on its own schedule — microseconds. Two hundred of them is well + /// under a frame at 60fps, and it drains ten thousand operations in about two and a + /// half seconds, which is inside the sidecar's reply timeout. + /// + /// + private const int PermOpsPerStep = 200; + + /// How often the sync drain runs while a job is in flight. + private const float PermStepSeconds = 0.05f; + + /// + /// The ceiling on one sync, in operations. + /// + /// + /// A frame larger than this is refused whole rather than applied for a while and then + /// abandoned. The website has its own limit below this one, so reaching it means + /// something is wrong rather than something is big — and a half-applied permission set + /// is the state nobody can reason about. + /// + /// + private const int PermMaxOps = 20000; + + /// The operations a compiled sync is made of. One store question each. + private enum PermOpKind + { + EnsureGroup, + EnsureGroupPermission, + EnsureMember, + EnsureGrant, + RetireGroupPermission, + RetireMember, + RetireGrant, + RetireGroup, + ScanPermission, + ScanGroup + } + + private struct PermOp + { + public PermOpKind Kind; + + /// A Steam id, or a group name — whichever the op is about. + public string Subject; + + /// A permission name, a group name, or null. + public string Object; + + /// Only uses these. + public string Title; + + public int Rank; + } + + /// + /// One sync in flight. There is at most one: a second arriving while this one drains is + /// answered `busy` rather than interleaved, because two writers reconciling the same + /// store against two different desired sets is a race with no correct outcome. + /// + private sealed class PermSyncJob + { + public string ReqId; + public string SetId; + public List Ops = new List(); + public int Next; + + public int Grants; + public int Revokes; + public int GroupsCreated; + public int GroupsRemoved; + public int GroupPermissions; + public int GroupPermissionsRemoved; + public int Members; + public int MembersRemoved; + public int AlreadyCorrect; + public int Absent; + + public readonly List Unresolved = new List(); + public readonly List Foreign = new List(); + public readonly List Pending = new List(); + + /// Permission names already reported unresolved, so each is reported once. + public readonly HashSet UnresolvedSeen = new HashSet(); + + /// Who the site says should hold each managed permission, for the foreign scan. + public readonly Dictionary> DesiredHolders = + new Dictionary>(); + + /// Who the site says is in each managed group, and what it should carry. + public readonly Dictionary> DesiredMembers = + new Dictionary>(); + + public readonly Dictionary> DesiredGroupPermissions = + new Dictionary>(); + } + + private PermSyncJob _permJob; + private Timer _permTimer; + + /// + /// True while this plugin is writing to the permission store. + /// + /// + /// The framework raises OnUserPermissionGranted and friends for every write, + /// including ours — so without this flag every sync would announce itself as drift and + /// the website would chase its own tail. It is set around each drain step rather than + /// for the whole job, so a write by somebody else BETWEEN steps is still seen. + /// + /// + private bool _applyingPermissions; + + /// The last report, for rg.perms. Diagnostics only. + private string _lastPermSummary = "none yet"; + + /// + /// Main thread. Everything this server knows about permissions and groups — the option + /// source the website's authoring form is built from (D33). + /// + /// + /// It is a live read rather than anything cached, because the answer changes when an + /// operator loads a plugin and the whole point of asking is to offer names that will + /// actually resolve. + /// + /// + private void HandlePermCatalogue(string reqId) + { + Dictionary reply = Frame("perm.catalogue", "reply"); + + var permissions = new List(); + string[] registered = permission.GetPermissions(); + + if (registered != null) + { + foreach (string name in registered) + { + if (!string.IsNullOrEmpty(name)) + permissions.Add(name); + } + } + + var groups = new List(); + string[] groupNames = permission.GetGroups(); + + if (groupNames != null) + { + foreach (string name in groupNames) + { + if (string.IsNullOrEmpty(name)) + continue; + + var row = new Dictionary + { + { "name", name }, + { "title", permission.GetGroupTitle(name) }, + { "rank", permission.GetGroupRank(name) } + }; + + var granted = new List(); + string[] groupPerms = permission.GetGroupPermissions(name, false); + + if (groupPerms != null) + { + foreach (string perm in groupPerms) + { + if (!string.IsNullOrEmpty(perm)) + granted.Add(perm); + } + } + + row["permissions"] = granted; + groups.Add(row); + } + } + + reply["permissions"] = permissions; + reply["groups"] = groups; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. Compiles a desired set into operations and starts draining it. + /// + /// + /// Nothing is written here. Compiling is pure bookkeeping over the frame, so a + /// malformed or enormous sync is refused before it has touched the store — there is no + /// state to unwind because none was made. + /// + /// + private void HandlePermSync(Dictionary cmd, string reqId) + { + if (_permJob != null) + { + EmitPermError(reqId, "busy"); + return; + } + + var job = new PermSyncJob + { + ReqId = reqId, + SetId = StringField(cmd, "setId") + }; + + // ── The desired set ──────────────────────────────────────────────── + // + // 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. + + foreach (Dictionary group in Rows(cmd, "groups")) + { + string name = Lower(StringField(group, "name")); + if (string.IsNullOrEmpty(name)) + continue; + + job.Ops.Add(new PermOp + { + Kind = PermOpKind.EnsureGroup, + Subject = name, + Title = StringField(group, "title") ?? name, + Rank = IntField(group, "rank") + }); + + HashSet wanted = Bucket(job.DesiredGroupPermissions, name); + + foreach (string perm in Strings(group, "permissions")) + { + string lowered = Lower(perm); + if (string.IsNullOrEmpty(lowered)) + continue; + + wanted.Add(lowered); + job.Ops.Add(new PermOp + { + Kind = PermOpKind.EnsureGroupPermission, + Subject = name, + Object = lowered + }); + } + + foreach (string member in Strings(group, "members")) + { + if (string.IsNullOrEmpty(member)) + continue; + + Bucket(job.DesiredMembers, name).Add(member); + job.Ops.Add(new PermOp + { + Kind = PermOpKind.EnsureMember, + Subject = member, + Object = name + }); + } + + // 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); + } + + foreach (Dictionary grant in Rows(cmd, "grants")) + { + string steamId = StringField(grant, "steamId"); + if (string.IsNullOrEmpty(steamId)) + continue; + + foreach (string perm in Strings(grant, "permissions")) + { + string lowered = Lower(perm); + if (string.IsNullOrEmpty(lowered)) + continue; + + Bucket(job.DesiredHolders, lowered).Add(steamId); + job.Ops.Add(new PermOp + { + Kind = PermOpKind.EnsureGrant, + Subject = steamId, + Object = lowered + }); + } + } + + // ── What the site has withdrawn ──────────────────────────────────── + // + // The site remembers what it put here, so a grant it has since removed arrives as an + // explicit retirement. It cannot be inferred from the desired set: a name that is not + // in it is either something the site withdrew or something a human granted, and those + // two have opposite correct answers (D31). + + foreach (Dictionary row in Rows(cmd, "retire")) + { + string kind = StringField(row, "kind"); + string subject = StringField(row, "subject"); + string target = Lower(StringField(row, "object")); + + if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(subject)) + continue; + + switch (kind) + { + case "grant": + job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGrant, Subject = subject, Object = target }); + break; + case "member": + job.Ops.Add(new PermOp { Kind = PermOpKind.RetireMember, Subject = subject, Object = target }); + break; + case "group-permission": + job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroupPermission, Subject = Lower(subject), Object = target }); + break; + case "group": + job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroup, Subject = Lower(subject) }); + break; + } + } + + // ── 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 }); + + if (job.Ops.Count > PermMaxOps) + { + EmitPermError(reqId, "too-large"); + Puts($"permission sync refused: {job.Ops.Count} operations exceeds {PermMaxOps}"); + return; + } + + _permJob = job; + _permTimer = timer.Every(PermStepSeconds, StepPermSync); + + // Run the first slice now rather than waiting out a tick: a small sync — which is every + // ordinary one — then finishes inside the call the website is waiting on. + StepPermSync(); + } + + /// + /// Main thread. Drains up to operations and, on the last + /// one, sends the report. + /// + private void StepPermSync() + { + PermSyncJob job = _permJob; + + if (job == null) + { + StopPermTimer(); + return; + } + + _applyingPermissions = true; + + try + { + int budget = PermOpsPerStep; + + while (budget-- > 0 && job.Next < job.Ops.Count) + { + PermOp op = job.Ops[job.Next++]; + + try + { + Apply(job, op); + } + catch (Exception ex) + { + // One bad operation must not abandon the set. The report still goes back, + // and the next sync retries what this one could not do. + Puts($"permission op {op.Kind} on '{op.Subject}' threw: {ex.Message}"); + } + } + } + finally + { + _applyingPermissions = false; + } + + if (job.Next < job.Ops.Count) + return; + + _permJob = null; + StopPermTimer(); + SendPermReport(job); + } + + private void StopPermTimer() + { + if (_permTimer == null) + return; + + _permTimer.Destroy(); + _permTimer = null; + } + + /// Main thread. One store question, and at most one store write. + private void Apply(PermSyncJob job, PermOp op) + { + switch (op.Kind) + { + case PermOpKind.EnsureGroup: + if (permission.GroupExists(op.Subject)) + { + job.AlreadyCorrect++; + } + else + { + permission.CreateGroup(op.Subject, op.Title, op.Rank); + job.GroupsCreated++; + } + + break; + + case PermOpKind.EnsureGroupPermission: + if (!Registered(job, op.Object)) + break; + + if (permission.GroupHasPermission(op.Subject, op.Object)) + { + job.AlreadyCorrect++; + } + else + { + permission.GrantGroupPermission(op.Subject, op.Object, this); + job.GroupPermissions++; + } + + break; + + case PermOpKind.EnsureMember: + if (permission.UserHasGroup(op.Subject, op.Object)) + { + job.AlreadyCorrect++; + break; + } + + permission.AddUserGroup(op.Subject, op.Object); + + // Read back, because this is the call that can do nothing at all. A player the + // store has never seen has no user record to put in a group (§12.2 rule 4), and + // the API says so by returning void and changing nothing. + if (permission.UserHasGroup(op.Subject, op.Object)) + job.Members++; + else + job.Pending.Add(op.Subject + ":" + op.Object); + + break; + + case PermOpKind.EnsureGrant: + if (!Registered(job, op.Object)) + break; + + if (HoldsDirectly(op.Subject, op.Object)) + { + job.AlreadyCorrect++; + } + else + { + permission.GrantUserPermission(op.Subject, op.Object, this); + job.Grants++; + } + + break; + + case PermOpKind.RetireGrant: + if (HoldsDirectly(op.Subject, op.Object)) + { + permission.RevokeUserPermission(op.Subject, op.Object); + job.Revokes++; + } + else + { + job.Absent++; + } + + break; + + case PermOpKind.RetireMember: + if (permission.UserHasGroup(op.Subject, op.Object)) + { + permission.RemoveUserGroup(op.Subject, op.Object); + job.MembersRemoved++; + } + else + { + job.Absent++; + } + + break; + + case PermOpKind.RetireGroupPermission: + if (permission.GroupHasPermission(op.Subject, op.Object)) + { + permission.RevokeGroupPermission(op.Subject, op.Object); + job.GroupPermissionsRemoved++; + } + else + { + job.Absent++; + } + + break; + + case PermOpKind.RetireGroup: + if (permission.GroupExists(op.Subject)) + { + permission.RemoveGroup(op.Subject); + job.GroupsRemoved++; + } + else + { + job.Absent++; + } + + break; + + case PermOpKind.ScanPermission: + ScanPermission(job, op.Subject); + break; + + case PermOpKind.ScanGroup: + ScanGroup(job, op.Subject); + break; + } + } + + /// + /// 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. + /// + private void ScanPermission(PermSyncJob job, string perm) + { + if (!permission.PermissionExists(perm)) + return; + + HashSet desired; + if (!job.DesiredHolders.TryGetValue(perm, out desired)) + desired = new HashSet(); + + 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 + { + { "kind", "grant" }, + { "subject", id }, + { "object", perm } + }); + } + } + + /// + /// 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. + /// + private void ScanGroup(PermSyncJob job, string group) + { + if (!permission.GroupExists(group)) + return; + + HashSet members; + if (!job.DesiredMembers.TryGetValue(group, out members)) + members = new HashSet(); + + 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 + { + { "kind", "member" }, + { "subject", id }, + { "object", group } + }); + } + } + + HashSet wanted; + if (!job.DesiredGroupPermissions.TryGetValue(group, out wanted)) + wanted = new HashSet(); + + 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; + + job.Foreign.Add(new Dictionary + { + { "kind", "group-permission" }, + { "subject", group }, + { "object", lowered } + }); + } + } + + /// + /// Whether this server has a plugin that registered the name, recording the answer once + /// per sync when it has not. + /// + /// + /// This is the pre-check PLAN.md §12.2 rule 1 exists for. Without it the grant below + /// would return void, throw nothing, log nothing and do nothing, and the website would + /// record a privilege it never gave. + /// + /// + private bool Registered(PermSyncJob job, string perm) + { + if (string.IsNullOrEmpty(perm)) + return false; + + if (permission.PermissionExists(perm)) + return true; + + if (job.UnresolvedSeen.Add(perm)) + job.Unresolved.Add(perm); + + return false; + } + + /// + /// Whether a player holds a permission through a grant of their own, as opposed to + /// through a group. + /// + /// + /// UserHasPermission cannot answer this — it is true for a member of a group + /// that carries the permission, so using it would make every direct grant look already + /// applied the moment a group covered it, and a revoke look already done. + /// GetPermissionUsers reads the user's own set, which is the question. + /// + /// + private bool HoldsDirectly(string steamId, string perm) + { + string[] holders = permission.GetPermissionUsers(perm); + + if (holders == null) + return false; + + foreach (string entry in holders) + { + if (UserId(entry) == steamId) + return true; + } + + return false; + } + + /// + /// The id out of a store listing entry. + /// + /// + /// Both GetPermissionUsers and GetUsersInGroup answer with the id and the + /// player's last known name stuck together — 76561198…(Wanderer) — and the + /// spacing differs between the two calls and between the frameworks. So the id is + /// everything before the first bracket, and a name containing one cannot confuse it + /// because only the first is looked for. + /// + /// + private static string UserId(string entry) + { + if (string.IsNullOrEmpty(entry)) + return null; + + int bracket = entry.IndexOf('('); + string id = bracket >= 0 ? entry.Substring(0, bracket) : entry; + + return id.Trim(); + } + + private void SendPermReport(PermSyncJob job) + { + var applied = new Dictionary + { + { "grants", job.Grants }, + { "revokes", job.Revokes }, + { "groupsCreated", job.GroupsCreated }, + { "groupsRemoved", job.GroupsRemoved }, + { "groupPermissions", job.GroupPermissions }, + { "groupPermissionsRemoved", job.GroupPermissionsRemoved }, + { "members", job.Members }, + { "membersRemoved", job.MembersRemoved } + }; + + Dictionary report = Frame("perm.report", "reply"); + report["applied"] = applied; + report["alreadyCorrect"] = job.AlreadyCorrect; + report["absent"] = job.Absent; + report["unresolved"] = job.Unresolved; + report["foreign"] = job.Foreign; + report["pending"] = job.Pending; + report["operations"] = job.Ops.Count; + + if (!string.IsNullOrEmpty(job.SetId)) + report["setId"] = job.SetId; + + if (job.ReqId != null) + report["reqId"] = job.ReqId; + + _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} " + + $"pending={job.Pending.Count} at={DateTime.UtcNow:HH:mm:ss}Z"; + + EmitFrame(report); + } + + /// A refusal of the whole sync, with a reason the website turns into a sentence. + private void EmitPermError(string reqId, string reason) + { + Dictionary frame = Frame("perm.error", "reply"); + frame["reason"] = reason; + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + // ---- 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 + // `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. + // + // That division is what makes them safe to trust at exactly the weight they deserve. A hook + // that stops firing on a framework upgrade costs latency, not correctness. + + private void OnUserPermissionGranted(string id, string permName) + { + MarkHook("OnUserPermissionGranted"); + EmitPermDrift("granted", id, permName, null); + } + + private void OnUserPermissionRevoked(string id, string permName) + { + MarkHook("OnUserPermissionRevoked"); + EmitPermDrift("revoked", id, permName, null); + } + + private void OnUserGroupAdded(string id, string groupName) + { + MarkHook("OnUserGroupAdded"); + EmitPermDrift("group-added", id, null, groupName); + } + + private void OnUserGroupRemoved(string id, string groupName) + { + MarkHook("OnUserGroupRemoved"); + EmitPermDrift("group-removed", id, null, groupName); + } + + private void OnGroupPermissionGranted(string name, string permName) + { + MarkHook("OnGroupPermissionGranted"); + EmitPermDrift("group-permission-granted", null, permName, name); + } + + private void OnGroupPermissionRevoked(string name, string permName) + { + MarkHook("OnGroupPermissionRevoked"); + EmitPermDrift("group-permission-revoked", null, permName, name); + } + + /// + /// Main thread. Says that the store changed under us, and by how much it is worth + /// caring. + /// + /// + /// Staff class (§8.5): it names a Steam id beside a privilege, which is a fact about a + /// person's standing rather than about what happened on the server. + /// + /// + private void EmitPermDrift(string action, string steamId, string perm, string group) + { + // Our own writes raise these hooks too. Suppressing them here rather than filtering on + // the website is the only version that works: the site cannot tell its own grant from + // a human's by looking at one, which is the entire reason drift needs a desired set. + if (_applyingPermissions) + return; + + Dictionary frame = Frame("perm.drift", "event"); + frame["action"] = action; + + if (!string.IsNullOrEmpty(steamId)) + frame["steamId"] = steamId; + + if (!string.IsNullOrEmpty(perm)) + frame["permission"] = perm; + + if (!string.IsNullOrEmpty(group)) + frame["group"] = group; + + EmitFrame(frame); + } + + // ---- reading a command's fields ---- + // + // `JsonConvert` hands back `object`, and every field below arrives as one. These are the + // three shapes this protocol uses, read defensively: a malformed field is a missing field, + // never an exception on the main thread. + + private static string StringField(Dictionary row, string key) + { + object value; + return row != null && row.TryGetValue(key, out value) && value != null + ? value.ToString() + : null; + } + + private static int IntField(Dictionary row, string key) + { + object value; + + if (row == null || !row.TryGetValue(key, out value) || value == null) + return 0; + + int parsed; + return int.TryParse(value.ToString(), out parsed) ? parsed : 0; + } + + /// The objects in an array field, skipping anything that is not one. + private static IEnumerable> Rows(Dictionary row, string key) + { + object value; + + if (row == null || !row.TryGetValue(key, out value)) + yield break; + + var list = value as Newtonsoft.Json.Linq.JArray; + + if (list == null) + yield break; + + foreach (Newtonsoft.Json.Linq.JToken item in list) + { + var obj = item as Newtonsoft.Json.Linq.JObject; + + if (obj == null) + continue; + + yield return obj.ToObject>(); + } + } + + /// The strings in an array field. + private static IEnumerable Strings(Dictionary row, string key) + { + object value; + + if (row == null || !row.TryGetValue(key, out value)) + yield break; + + var list = value as Newtonsoft.Json.Linq.JArray; + + if (list == null) + yield break; + + foreach (Newtonsoft.Json.Linq.JToken item in list) + { + if (item == null || item.Type == Newtonsoft.Json.Linq.JTokenType.Null) + continue; + + yield return item.ToString(); + } + } + + /// + /// Permission and group names are compared case-insensitively because both frameworks + /// store them lowered. Comparing what the site sent against what the store holds + /// without this makes `Kits.VIP` and `kits.vip` two different privileges, one of which + /// is granted for ever and never found again. + /// + private static string Lower(string value) + { + return value == null ? null : value.Trim().ToLowerInvariant(); + } + + private static HashSet Bucket(Dictionary> map, string key) + { + HashSet bucket; + + if (!map.TryGetValue(key, out bucket)) + { + bucket = new HashSet(); + map[key] = bucket; + } + + return bucket; + } + // ---- diagnostics ---- /// @@ -2015,6 +3013,30 @@ namespace Oxide.Plugins $"wipeId={(_wipeId ?? "none")}"); } + /// + /// What the last permission sync did, from the server console. + /// + /// + /// It is the fastest way to tell the two silent failures apart on a rig: a sync that + /// reported `unresolved` names granted nothing because no loaded plugin registered + /// them, and a sync that reported `pending` members could not put somebody in a group + /// because the store has never seen them. Both look like "the website says they have + /// it and the game disagrees" from the other end. + /// + /// + [ConsoleCommand("rg.perms")] + private void CmdPerms(ConsoleSystem.Arg arg) + { + string[] groups = permission.GetGroups(); + string[] registered = permission.GetPermissions(); + + arg.ReplyWith( + $"protocol={ProtocolVersion} registered={(registered != null ? registered.Length : 0)} " + + $"groups={(groups != null ? groups.Length : 0)} " + + $"running={(_permJob != null)}" + Environment.NewLine + + $"last sync: {_lastPermSummary}"); + } + /// /// Which of this plugin's hooks have actually fired, and how often. ///