Homelab Academy / Modules
Guided practice + break/fix + lab gate.
Module: Edge Access & VPN
Activity type: Lab (Practice)
Lab risk: low
Objective: Explain the difference between identification, authentication, authorization, and accounting.
Build and test a default-deny RBAC decision path
Run this exercise only on a disposable or authorized homelab system.
Every filesystem mutation made by the exercise is confined to /opt/lab-classroom/class67/.
The exercise does not create operating-system users, change host authentication, or expose a network listener.
The sample credentials exist only inside the short-lived Python process and are not production secrets.
1
Create the dedicated lab directory with restrictive default permissions.
umask 077 && mkdir -p /opt/lab-classroom/class67/
2
Run the demonstration. It builds an in-memory identity store, executes six tests, and appends JSON audit events to the dedicated lab directory.
umask 077 && python3 - <<'PY'
import hashlib
import hmac
import json
import secrets
from datetime import datetime, timezone
from pathlib import Path
BASE = Path('/opt/lab-classroom/class67')
AUDIT = BASE / 'audit.jsonl'
ITERATIONS = 210_000
GENERIC_FAILURE = 'Invalid credentials or account unavailable'
PERMISSIONS = {
'viewer': {'inventory.read'},
'operator': {'inventory.read', 'service.restart'},
'administrator': {'inventory.read', 'service.restart', 'identity.manage'},
}
def derive(password, salt):
return hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, ITERATIONS)
def record_for(password, role, enabled=True):
salt = secrets.token_bytes(16)
return {
'salt': salt,
'verifier': derive(password, salt),
'role': role,
'enabled': enabled,
}
USERS = {
'alice': record_for('class67-alice-demo', 'viewer'),
'bob': record_for('class67-bob-demo', 'operator'),
'carol': record_for('class67-carol-demo', 'administrator', enabled=False),
}
DUMMY_SALT = secrets.token_bytes(16)
DUMMY_VERIFIER = derive('dummy-value-never-used-for-login', DUMMY_SALT)
def audit(event, fields):
entry = {
'timestamp': datetime.now(timezone.utc).isoformat(),
'event': event,
fields,
}
with AUDIT.open('a', encoding='utf-8') as handle:
handle.write(json.dumps(entry, sort_keys=True) + '\n')
def authenticate(username, password):
account = USERS.get(username)
salt = account['salt'] if account else DUMMY_SALT
expected = account['verifier'] if account else DUMMY_VERIFIER
supplied = derive(password, salt)
password_matches = hmac.compare_digest(supplied, expected)
if account and password_matches and account['enabled']:
audit('authentication_success', username=username)
return {'username': username, 'role': account['role']}, None
reason = 'unknown_user'
if account and not password_matches:
reason = 'invalid_password'
elif account and password_matches and not account['enabled']:
reason = 'account_disabled'
audit('authentication_failure', username=username, reason=reason)
return None, GENERIC_FAILURE
def authorize(principal, permission, resource):
if not principal:
return False
allowed = permission in PERMISSIONS.get(principal['role'], set())
audit(
'authorization_allowed' if allowed else 'authorization_denied',
username=principal['username'],
role=principal['role'],
permission=permission,
resource=resource,
)
return allowed
def check(label, condition):
if not condition:
raise AssertionError(label)
print('PASS:', label)
principal, error = authenticate('alice', 'class67-alice-demo')
check('viewer authentication succeeds', principal is not None and error is None)
check('viewer may read inventory', authorize(principal, 'inventory.read', 'node-01'))
check('viewer may not restart service', not authorize(principal, 'service.restart', 'node-01'))
principal, error = authenticate('bob', 'class67-bob-demo')
check('operator authentication succeeds', principal is not None and error is None)
check('operator may restart service', authorize(principal, 'service.restart', 'node-01'))
principal, error = authenticate('carol', 'class67-carol-demo')
check('disabled account is rejected generically', principal is None and error == GENERIC_FAILURE)
principal, error = authenticate('alice', 'wrong-password')
check('wrong password is rejected generically', principal is None and error == GENERIC_FAILURE)
principal, error = authenticate('mallory', 'anything')
check('unknown user is rejected generically', principal is None and error == GENERIC_FAILURE)
print('Audit path:', AUDIT)
PY
3
Inspect the audit file without displaying or searching for any real secrets. Confirm that the file contains parseable JSON events and that denied operations are represented.
python3 - <<'PY'
import json
from pathlib import Path
p = Path('/opt/lab-classroom/class67/audit.jsonl')
rows = [json.loads(line) for line in p.read_text(encoding='utf-8').splitlines() if line.strip()]
print('event_count=', len(rows))
print('event_types=', sorted({row['event'] for row in rows}))
print('denied_permissions=', [row.get('permission') for row in rows if row['event'] == 'authorization_denied'])
assert rows
assert any(row['event'] == 'authentication_success' for row in rows)
assert any(row['event'] == 'authentication_failure' for row in rows)
assert any(row['event'] == 'authorization_allowed' for row in rows)
assert any(row['event'] == 'authorization_denied' for row in rows)
assert all('password' not in row for row in rows)
print('PASS: audit verification complete')
PY
Why is the role checked again when a permission is requested even though authentication already succeeded?
What additional resource attributes would be needed to prevent an operator from restarting services on nodes outside the operator's assigned environment?
Which audit fields would help an incident responder without unnecessarily exposing secrets or personal data?
How would session expiration, role changes, and account disablement affect an already authenticated session in a production design?
| Symptom | Likely cause | Fix |
|---|---|---|
| Creating the lab directory reports permission denied. | The current account cannot create directories beneath /opt, or the class directory has an unexpected owner. | Use an authorized administrative workflow to create only /opt/lab-classroom/class67/ for the learner, then rerun the lab as the learner. Do not broaden unrelated filesystem permissions. |
| The shell reports that python3 cannot be found. | Python 3 is not installed or is not available through the current command search path. | Install a vendor-supported Python 3 package using the host's normal package-management process, then confirm that python3 --version works before returning to the lab. |
| The demonstration ends with an AssertionError. | The copied program was altered, truncated, or executed by a shell that did not preserve the here-document exactly. | Start a fresh terminal, copy the complete command from step 2, and verify that the closing PY marker begins at the first column with no extra characters. |
| The audit event count is larger than expected. | The lab appends events and has been run more than once. | This is expected. Evaluate the presence and contents of event types rather than requiring one fixed count, or perform the documented rollback before a clean rerun. |
| Audit verification reports invalid JSON. | The audit file was manually edited, a previous process was interrupted during a write, or unrelated content was placed in the file. | Inspect only /opt/lab-classroom/class67/audit.jsonl, preserve it if investigation is needed, then perform rollback and rerun the lab to generate a clean file. |
| Rollback reports that the directory is not empty. | Additional files were placed in the class directory after the lab ran. | List and review the directory contents. Remove or relocate only files you have positively identified as class artifacts, then rerun the narrow rollback command. Do not replace it with an unbounded recursive deletion. |
Deny by default and explicitly grant narrowly defined permissions.
Separate authentication from authorization and log their outcomes independently.
Apply server-side authorization to every protected operation and object.
Use unique salts, reviewed password-derivation settings, and constant-time verifier comparison.
Use generic external authentication errors while preserving limited diagnostic reasons in protected audit data.
Treat account recovery, enrollment, factor replacement, and emergency access as security-sensitive authentication paths.
Review role assignments periodically and revoke access promptly when responsibilities change.
Protect session identifiers and require expiration, revocation, and reauthentication for sensitive actions.
Do not include passwords, private keys, session tokens, or complete recovery credentials in logs.
The in-memory identity store is educational and is not a production identity database.
The sample work factor is not presented as a benchmark or universal configuration recommendation.
The exercise does not implement throttling, persistent lockout state, session management, federation, cryptographic key management, or multi-factor enrollment.
A dummy password derivation reduces a simple user-enumeration difference but does not prove that all observable timing is identical.
The audit file is locally writable by the lab owner and is not an append-only or remotely protected logging system.
Prefer mature, maintained identity platforms over custom authentication code.
Use phishing-resistant authentication for privileged administration where supported.
Rate-limit authentication attempts using controls that resist denial-of-service abuse.
Inventory service accounts and use short-lived workload identities where possible.
Test object-level authorization because permission to access one object does not imply permission to access every object of the same type.
Generate alerts for privilege changes, disabled-account attempts, suspicious recovery activity, and repeated authorization denials.
Maintain tested emergency-access procedures with strong monitoring and post-use review.
Remove only the audit file created by this lesson and then remove the class directory if it is empty.
Run: python3 -c "from pathlib import Path; p=Path('/opt/lab-classroom/class67'); print([x.name for x in p.iterdir()] if p.exists() else 'already absent')"
python3 -c "from pathlib import Path; p=Path('/opt/lab-classroom/class67'); (p/'audit.jsonl').unlink(missing_ok=True); p.rmdir()"
The directory removal intentionally fails if any unexpected file remains. This prevents the rollback from recursively deleting unreviewed content.
Run: python3 -c "from pathlib import Path; p=Path('/opt/lab-classroom/class67'); print('PASS' if not p.exists() else 'REVIEW REQUIRED')"