Role-Based Access Control for LMS Data in Python

Tokenizing identifiers keeps raw PII out of the warehouse, but FERPA’s disclosure limits demand a second control on top: even de-identified LMS data must not be uniformly visible. An analyst should see aggregate, token-only columns; an instructor should see only the sections they teach; and only registrar tooling should reach a reversible identifier. This guide enforces that with role-based access control (RBAC) in Python, operating on two axes simultaneously — column-level filtering (drop tokenized-PII and quasi-identifier columns per role) and row-level filtering (an instructor sees only their own sections) — with every denial raised loudly and audited. It is the serving-layer control that completes the FERPA-Compliant PII Handling design, sitting downstream of tokenization and beside the audit log that records each decision.

The task: define a role→permission policy, then a guard that takes a principal (role plus scope) and a DataFrame and returns only the columns and rows that principal may see — raising on any attempt to exceed the grant, rather than silently returning less.

Prerequisites

Confirm each before running the procedure — the script assumes all of them.

Step-by-step implementation

1. Define roles as an explicit policy, not scattered if checks. Each role maps to a permission set: which column classes it may see and whether it is row-scoped. Centralizing this as data means a compliance reviewer reads one policy table, and adding a role never means hunting for conditionals.

2. Deny by default. An unknown role, or a column not covered by the policy, resolves to no access — fail closed. A newly-added column is invisible until the policy explicitly admits it, so a schema change cannot leak a field.

3. Filter columns by class. Drop every column whose classification the role is not permitted to see. The analyst role permits analytical only, so tokenized_pii and quasi_identifier columns are removed before the frame is returned — an analyst never receives even a token, let alone a reversible id.

4. Filter rows by scope. For a row-scoped role like instructor, keep only rows whose section_id is in the principal’s scope. Attempting to read outside scope is a violation, not an empty result — the guard raises so the breach is visible rather than masked.

5. Audit every decision. Both a successful access and a denial write an audit row (actor, action, purpose, outcome). The guard that filters data and the log that records it are one operation, closing the same loop as the ingestion gate.

Complete runnable code block

python
from dataclasses import dataclass, field
from enum import Enum

import pandas as pd


class ColumnClass(str, Enum):
    ANALYTICAL = "analytical"          # scores, counts — safe for analysts
    QUASI_IDENTIFIER = "quasi_id"      # dob, section — re-identifying in combination
    TOKENIZED_PII = "tokenized_pii"    # stu_ tokens — still restricted


class AccessDenied(Exception):
    """Raised when a principal exceeds its grant — never silently narrowed."""


@dataclass(frozen=True)
class RolePolicy:
    visible_classes: frozenset[ColumnClass]
    row_scoped: bool                   # True => filtered to the principal's scope
    may_reverse_token: bool = False    # only registrar tooling


# Deny-by-default: a role absent here has no access at all.
POLICY: dict[str, RolePolicy] = {
    "analyst": RolePolicy(frozenset({ColumnClass.ANALYTICAL}), row_scoped=False),
    "instructor": RolePolicy(
        frozenset({ColumnClass.ANALYTICAL, ColumnClass.QUASI_IDENTIFIER,
                   ColumnClass.TOKENIZED_PII}), row_scoped=True),
    "registrar": RolePolicy(
        frozenset({ColumnClass.ANALYTICAL, ColumnClass.QUASI_IDENTIFIER,
                   ColumnClass.TOKENIZED_PII}), row_scoped=False,
        may_reverse_token=True),
}


@dataclass
class Principal:
    actor_token: str
    role: str
    sections: frozenset[str] = frozenset()   # row scope for instructors


@dataclass
class Guard:
    schema: dict[str, ColumnClass]           # column -> classification
    audit: list[dict] = field(default_factory=list)

    def _policy(self, role: str) -> RolePolicy:
        policy = POLICY.get(role)
        if policy is None:
            raise AccessDenied(f"unknown role {role!r} — denied by default")
        return policy

    def apply(self, df: pd.DataFrame, principal: Principal, purpose: str) -> pd.DataFrame:
        policy = self._policy(principal.role)

        # Column filter: keep only columns whose class this role may see.
        allowed = [c for c in df.columns
                   if self.schema.get(c, ColumnClass.TOKENIZED_PII) in policy.visible_classes]

        # Row filter: a row-scoped role sees only its own sections.
        view = df
        if policy.row_scoped:
            if "section_id" not in df.columns:
                raise AccessDenied("row-scoped role requires a section_id column")
            if not principal.sections:
                raise AccessDenied(f"{principal.role} has empty section scope")
            view = df[df["section_id"].isin(principal.sections)]

        result = view[allowed].copy()
        self.audit.append({"actor": principal.actor_token, "role": principal.role,
                           "action": "read", "purpose": purpose,
                           "rows": int(len(result)), "cols": allowed})
        return result

    def reverse_token(self, principal: Principal, token: str) -> str:
        if not self._policy(principal.role).may_reverse_token:
            self.audit.append({"actor": principal.actor_token, "role": principal.role,
                               "action": "reverse_token", "outcome": "denied"})
            raise AccessDenied(f"{principal.role} may not reverse tokens")
        self.audit.append({"actor": principal.actor_token, "role": principal.role,
                           "action": "reverse_token", "outcome": "allowed"})
        return f"raw_for_{token}"   # real impl reads the restricted vault


if __name__ == "__main__":
    frame = pd.DataFrame({
        "learner_token": ["stu_v1_a", "stu_v1_b", "stu_v1_c"],
        "section_id": ["S1", "S1", "S2"],
        "dob": ["2003-01-01", "2004-05-09", "2003-11-20"],
        "score": [88.0, 91.5, 79.0],
    })
    guard = Guard(schema={
        "learner_token": ColumnClass.TOKENIZED_PII,
        "section_id": ColumnClass.QUASI_IDENTIFIER,
        "dob": ColumnClass.QUASI_IDENTIFIER,
        "score": ColumnClass.ANALYTICAL,
    })

    analyst = Principal("stu_v1_an", "analyst")
    print(guard.apply(frame, analyst, purpose="term_report"))     # only 'score'

    prof = Principal("stu_v1_pr", "instructor", sections=frozenset({"S1"}))
    print(guard.apply(frame, prof, purpose="advising"))           # S1 rows, all cols

    try:
        guard.reverse_token(prof, "stu_v1_a")                     # raises AccessDenied
    except AccessDenied as exc:
        print("denied:", exc)

Verification and output validation

  • Analysts get no identifiers. assert set(guard.apply(frame, analyst, "t").columns) == {"score"} — a tokenized_pii or quasi_identifier column reaching an analyst is a leak.
  • Instructors are row-scoped. The instructor over {"S1"} receives exactly the two S1 rows; assert (out["section_id"] == "S1").all(). An S2 row appearing means the row filter did not run.
  • Empty scope is a denial, not empty data. An instructor with sections=frozenset() raises AccessDenied, so a mis-provisioned account fails loudly rather than seeing nothing and assuming there is nothing to see.
  • Unknown roles denied. Principal("x", "vendor_bot") raises AccessDenied — deny-by-default holds.
  • Token reversal is registrar-only. guard.reverse_token(prof, …) raises and writes a denied audit row; the same call as a registrar principal succeeds and writes an allowed row.
  • Every call is audited. len(guard.audit) equals the number of access and reversal attempts, successful or denied — no path bypasses the log.

Troubleshooting

  • An analyst receives a token column. The column was missing from schema, so the guard’s default (TOKENIZED_PII) should have hidden it — check you did not default unknown columns to ANALYTICAL. Fail closed: an unclassified column must be treated as the most restricted class.
  • KeyError on section_id for an instructor. The frame reached the guard without the scoping column. Row-scoped roles require section_id; join it in during normalization, or the guard cannot enforce row-level access and rightly raises.
  • Instructor sees another section’s students. The sections scope was over-broad or built from stale enrollment. Rebuild the scope from current section assignments each session; RBAC is only as tight as the scope it is handed.
  • Denials are silent (empty frame instead of an error). Code caught AccessDenied and returned df.head(0), masking the violation. Let the exception propagate so the audit records a denial and the caller cannot mistake “not allowed” for “no data.”
  • Registrar reversal is not audited. A reversal path called the vault directly instead of reverse_token. Route all reversals through the guard so action='reverse_token' reaches the FERPA audit log.
  • Two roles need overlapping-but-different columns. Resist per-column role checks; add a new ColumnClass or a new RolePolicy entry instead, keeping the policy declarative and reviewable as the FERPA-Compliant PII Handling design intends.

Part of: FERPA-Compliant PII Handling