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
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"}— atokenized_piiorquasi_identifiercolumn 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()raisesAccessDenied, 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")raisesAccessDenied— deny-by-default holds. - Token reversal is registrar-only.
guard.reverse_token(prof, …)raises and writes adeniedaudit row; the same call as aregistrarprincipal succeeds and writes anallowedrow. - 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 toANALYTICAL. Fail closed: an unclassified column must be treated as the most restricted class. KeyErroronsection_idfor an instructor. The frame reached the guard without the scoping column. Row-scoped roles requiresection_id; join it in during normalization, or the guard cannot enforce row-level access and rightly raises.- Instructor sees another section’s students. The
sectionsscope 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
AccessDeniedand returneddf.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 soaction='reverse_token'reaches the FERPA audit log. - Two roles need overlapping-but-different columns. Resist per-column role checks; add a new
ColumnClassor a newRolePolicyentry instead, keeping the policy declarative and reviewable as the FERPA-Compliant PII Handling design intends.
Related
- FERPA-Compliant PII Handling — the integrated design in which this access control is the serving-layer control.
- Designing a FERPA Audit-Log Schema in Postgres — the append-only log that records every access and denial this guard produces.
- Implementing Salted SHA-256 Tokenization for Student IDs — the tokens whose visibility this policy governs, and the vault the registrar reverses.
- LMS Data Architecture & Schema Mapping — the serving-layer row- and column-level access requirements this enforces.
Part of: FERPA-Compliant PII Handling