From 901983226bfe2d238a6a985fbc1b5c1887217125 Mon Sep 17 00:00:00 2001 From: RayDev Date: Mon, 28 Sep 2026 15:35:31 +0100 Subject: [PATCH] docs(governance): add privilege revocation policy --- Governance/policies/REVOCATION.md | 176 ++++++++++++++++++++ Governance/policies/REVOCATION.test.ts | 220 +++++++++++++++++++++++++ 2 files changed, 396 insertions(+) create mode 100644 Governance/policies/REVOCATION.md create mode 100644 Governance/policies/REVOCATION.test.ts diff --git a/Governance/policies/REVOCATION.md b/Governance/policies/REVOCATION.md new file mode 100644 index 00000000..a0a57884 --- /dev/null +++ b/Governance/policies/REVOCATION.md @@ -0,0 +1,176 @@ +# Privilege Revocation Policy + +## Purpose + +This policy defines how TeachLink Web revokes repository and project +privileges that have already been granted: repository access levels, team +and working-group membership, credentials used by automation and release +tooling, and other named authorities. It states the grounds on which a +privilege may be revoked, who may initiate a revocation, the steps every +revocation follows, and the appeal path available to the affected holder. +It exists so that revocation is prompt when it must be, and documented, +reviewable, and non-arbitrary at all other times. + +## Scope + +This policy applies to every privilege granted through the project, +including but not limited to: + +- Repository access levels: read, triage, write, maintain, and administer. +- Team, working-group, and security-response membership. +- Credentials and authorities used by bots, automation, and release tooling. +- Any other named authority recorded in this `Governance/` folder. + +It does not cover enforcement of conduct standards, which follows the Code +of Conduct and its appeals process, nor the ordinary removal of access that +simply follows inactivity, which follows the Inactivity Policy. Where this +policy and another governance document both apply, the stricter requirement +governs. + +## Grounds for Revocation + +A privilege may be revoked only on an enumerated ground: + +- **Security risk.** The holder's credentials, devices, or accounts are + compromised, or the holder poses an active risk to the repository, its + users, or its releases. +- **Confirmed misconduct.** A conduct enforcement decision under the Code + of Conduct requires or recommends removal of the privilege. +- **Breach of trust.** Deliberate abuse of the privilege, such as bypassing + required checks, leaking private data, or acting against the project while + using the granted authority. +- **Loss of competence or availability.** The holder cannot exercise the + privilege safely under the applicable role requirements. +- **Legal or platform requirement.** A law, court order, or hosting platform + requires the privilege to be removed. + +A revocation is never used as a general sanction. It is proportionate to the +ground, and the record states which ground applies and why. + +## Who May Initiate + +- **Maintainers** may open a revocation for any holder, under any ground, as + defined in `Governance/roles/MAINTAINER.md`. +- **The Security Response Team** may open a revocation on security grounds, + as described in `Governance/SECURITY_RESPONSE_TEAM.md`, and may act first + and document afterwards when there is an active risk to users. +- **The relevant working group or role owner** may open a revocation for the + privileges it grants, for its own members. +- **The holder** may voluntarily surrender a privilege at any time by + telling a maintainer. A voluntary surrender follows the record steps but + does not require the grounds test. + +An initiator may not decide a revocation in which they are the affected +holder or the subject, and must disclose any personal stake under +`Governance/policies/CONFLICT_OF_INTEREST.md` and step aside when one exists. + +## Revocation Steps + +Every revocation moves through the following steps in order, except that a +security revocation may compress steps 1 and 2 when there is an active risk +to users, with the compression and its reason recorded. + +### 1. Open a revocation record + +The initiator opens a private revocation record that names the holder, the +privilege, the ground relied on, the evidence, and who raised it. Reports +that do not name a ground are sent back for that detail rather than actioned. + +- **Owner:** the initiator. +- **Exit criterion:** a private record exists with holder, privilege, + ground, and evidence. + +### 2. Independent decision + +An independent decision-maker reviews the record against the enumerated +grounds and decides to revoke, to restrict instead, or to decline. A +revocation that removes a privilege on a ground not listed above is invalid. + +- **Owner:** a maintainer or delegate who is independent of the holder. +- **Exit criterion:** a written decision that names the ground and the + reasoning, within 5 business days of the record being complete. + +### 3. Apply the removal + +The privilege is removed through the platform or tooling that grants it, and +every credential, token, or key issued for it is rotated or revoked. Removal +is complete: nothing that grants the privilege is left active. + +- **Owner:** maintainers with the access to apply the change. +- **Exit criterion:** the access, membership, or credential is gone, and the + platform audit log confirms it. + +### 4. Notify and record + +The holder is notified privately of the decision, the privilege removed, the +ground, and the appeal path in this document. The decision and its reasoning +are recorded where maintainers can audit them; public spaces carry only the +fact that a change was made, never private details. + +- **Owner:** maintainers. +- **Exit criterion:** holder notified and the decision recorded. + +### 5. Follow up + +When the revocation exposed a gap in access control, review, or process, a +follow-up issue is opened without disclosing private details. Retaliation for +participating in a revocation is treated as a fresh conduct matter. + +- **Owner:** maintainers. +- **Exit criterion:** any follow-up issue opened and linked to the record. + +## Appeal Path + +Every holder whose privilege is revoked may appeal that decision: + +- **Who may appeal.** The affected holder, or a maintainer acting on behalf + of a holder who cannot safely appeal alone. An appeal may use a pseudonym + where safety requires it. +- **How to appeal.** File within 14 calendar days of the decision notice in + the private channel named in that notice, stating which decision is + appealed, why it should be reconsidered, and any new evidence. +- **Independent review.** An independent reviewer, uninvolved in the + original decision, examines the record and the appeal grounds and decides + to uphold, amend, or overturn, within 10 business days of assignment. +- **Written decision with reasoning.** The outcome is communicated to the + appellant in writing, stating what was decided, why, and what could reopen + it. +- **Re-review.** A party may request re-review once with new evidence; after + that the decision stands unless new facts emerge. +- **Exceptions.** A security or legal revocation may remain in force during + an appeal when the risk is ongoing. The exception, its reason, and a + retrospective independent review are recorded, as described in + `Governance/processes/ESCALATION_PATH.md`. + +A review that merely restates the original decision without engaging the +appeal grounds is not an independent review. + +## Ownership and Review + +- Maintainers own this policy and are accountable for applying its grounds + and steps consistently and for recording every revocation and exception. +- Changes to this policy are proposed in a pull request that touches only + the `Governance/` folder, and are reviewed like any other governance + change. +- This document is versioned with the repository and describes the + revocation practice the project actually follows. + +## Success + +This policy succeeds when every revocation names an enumerated ground, when +removals are complete and their credentials rotated, when every affected +holder is told the decision and their appeal path, and when the record lets +the project review its own access decisions without exposing private +details. + +## Regression Tests + +Coverage is provided by `Governance/policies/REVOCATION.test.ts`, which pins +the grounds, the initiation rights, and the appeal guarantees in this +document. + +## Revision History + +| Version | Date | Change | Author | +| --- | --- | --- | --- | +| 1.0 | 2026-09-28 | Initial version. | TeachLink maintainers | diff --git a/Governance/policies/REVOCATION.test.ts b/Governance/policies/REVOCATION.test.ts new file mode 100644 index 00000000..af6e039d --- /dev/null +++ b/Governance/policies/REVOCATION.test.ts @@ -0,0 +1,220 @@ +/** + * Regression tests for the Privilege Revocation Policy + * (Governance/policies/REVOCATION.md). + * + * The policy is documentation, but it makes concrete, enforceable + * guarantees: a closed list of grounds, named parties who may initiate a + * revocation, a fixed sequence of steps, and an appeal path with real + * windows. These tests pin those guarantees to the checked-in document so + * they cannot silently regress (for example, an edit that adds an + * unenumerated ground or drops the appeal path fails CI), and they keep the + * document consistent with the rest of `Governance/`. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const POLICY_PATH = path.resolve(__dirname, 'REVOCATION.md'); +const policy = readFileSync(POLICY_PATH, 'utf8'); + +/** Strip Markdown syntax so keyword assertions match prose, not formatting. */ +function plainProse(markdown: string): string { + return markdown + .replace(/`([^`]*)`/g, '$1') // inline code keeps its text + .replace(/\*\*([^*]*)\*\*/g, '$1') // bold keeps its text + .replace(/\[([^\]]*)\]\(([^)]*)\)/g, '$1 $2') // links keep text and target + .replace(/\s+/g, ' ') // line wrapping must not affect prose matching + .toLowerCase(); +} + +const prose = plainProse(policy); + +/** Every "## Heading" in the document, in order. */ +const sections = [...policy.matchAll(/^## (.+)$/gm)].map((match) => match[1]); + +/** Extract the body of a single "## Section" (text up to the next heading). */ +function sectionBody(title: string): string { + const start = policy.indexOf(`## ${title}\n`); + expect(start, `section "${title}" is missing`).toBeGreaterThanOrEqual(0); + const next = policy.indexOf('\n## ', start + 1); + const body = next === -1 ? policy.slice(start) : policy.slice(start, next); + return plainProse(body); +} + +describe('REVOCATION policy document structure', () => { + it('is titled "Privilege Revocation Policy"', () => { + expect(policy.startsWith('# Privilege Revocation Policy\n')).toBe(true); + }); + + it('keeps the canonical governance document sections', () => { + expect(sections).toEqual([ + 'Purpose', + 'Scope', + 'Grounds for Revocation', + 'Who May Initiate', + 'Revocation Steps', + 'Appeal Path', + 'Ownership and Review', + 'Success', + 'Regression Tests', + 'Revision History', + ]); + }); + + it('covers the three areas the issue requires', () => { + expect(sections).toContain('Grounds for Revocation'); + expect(sections).toContain('Who May Initiate'); + expect(sections).toContain('Appeal Path'); + }); + + it('has no unresolved template placeholders', () => { + expect(policy).not.toMatch(/TBD|TODO|FIXME|<[a-z-]+>|XXX/); + }); + + it('stays within the house documentation line width (max 82 columns)', () => { + const longest = Math.max(...policy.split('\n').map((line) => line.length)); + expect(longest).toBeLessThanOrEqual(82); + }); +}); + +describe('grounds for revocation guarantees', () => { + const body = sectionBody('Grounds for Revocation'); + + it('is a closed list rather than an open-ended sanction', () => { + expect(body).toContain('only on an enumerated ground'); + expect(body).toContain('never used as a general sanction'); + }); + + it('includes the five enumerated grounds', () => { + const required = [ + 'security risk', + 'confirmed misconduct', + 'breach of trust', + 'loss of competence or availability', + 'legal or platform requirement', + ]; + for (const ground of required) { + expect(body).toContain(ground); + } + }); + + it('requires the record to name the ground relied on', () => { + expect(body).toContain('states which ground applies'); + }); +}); + +describe('who may initiate guarantees', () => { + const body = sectionBody('Who May Initiate'); + + it('names maintainers, the security team, role owners, and the holder', () => { + expect(body).toContain('maintainers'); + expect(body).toContain('security response team'); + expect(body).toContain('working group or role owner'); + expect(body).toContain('voluntarily surrender'); + }); + + it('lets the security team act first on an active risk and document after', () => { + expect(body).toContain('act first'); + expect(body).toContain('active risk'); + }); + + it('forbids an initiator deciding a revocation they are subject to', () => { + expect(body).toContain('may not decide'); + }); + + it('requires a conflict-of-interest disclosure', () => { + expect(body).toContain('conflict_of_interest.md'); + }); +}); + +describe('revocation step guarantees', () => { + const body = sectionBody('Revocation Steps'); + + it('opens a record with holder, privilege, ground, and evidence', () => { + expect(body).toContain('holder'); + expect(body).toContain('privilege'); + expect(body).toContain('ground'); + expect(body).toContain('evidence'); + }); + + it('requires an independent decision-maker', () => { + expect(body).toContain('independent'); + }); + + it('rotates every credential and revokes issued keys on removal', () => { + expect(body).toContain('rotated or revoked'); + }); + + it('notifies the holder and records the decision', () => { + expect(body).toContain('notified'); + expect(body).toContain('recorded'); + }); +}); + +describe('appeal path guarantees', () => { + const body = sectionBody('Appeal Path'); + + it('offers an appeal to every affected holder', () => { + expect(body).toContain('may appeal'); + }); + + it('sets a 14 calendar day filing window', () => { + expect(body).toContain('14 calendar days'); + }); + + it('requires an independent review decided within 10 business days', () => { + expect(body).toContain('independent reviewer'); + expect(body).toContain('10 business days'); + }); + + it('requires a written decision with reasoning', () => { + expect(body).toContain('written decision with reasoning'); + }); + + it('allows one re-review with new evidence', () => { + expect(body).toContain('re-review once'); + }); + + it('keeps a security revocation in force during appeal only with a record', () => { + expect(body).toContain('security or legal'); + expect(body).toContain('retrospective independent review'); + }); + + it('rejects a review that merely restates the original decision', () => { + expect(body).toContain('not an independent review'); + }); +}); + +describe('policy consistency with the governance folder', () => { + it('routes escalation to the escalation-path process document', () => { + expect(prose).toContain('governance/processes/escalation_path.md'); + expect(() => + readFileSync(path.resolve(__dirname, '../processes/ESCALATION_PATH.md'), 'utf8'), + ).not.toThrow(); + }); + + it('references the maintainer role document that exists', () => { + expect(prose).toContain('governance/roles/maintainer.md'); + expect(() => + readFileSync(path.resolve(__dirname, '../roles/MAINTAINER.md'), 'utf8'), + ).not.toThrow(); + }); + + it('references the security response team charter that exists', () => { + expect(prose).toContain('governance/security_response_team.md'); + expect(() => + readFileSync(path.resolve(__dirname, '../SECURITY_RESPONSE_TEAM.md'), 'utf8'), + ).not.toThrow(); + }); + + it('mentions the governance-folder-only rule for changes to this policy', () => { + const body = sectionBody('Ownership and Review'); + expect(body).toContain('only the governance/ folder'); + }); + + it('keeps the change self-contained in the governance folder', () => { + const body = sectionBody('Scope'); + expect(body).toContain('repository access levels'); + expect(body).toContain('credentials'); + }); +});