Lesson 13 / 19
Authentication and Authorization
Whether an authorization decision comes from an annotation, from the container's default, or from a check written directly in the source is separated by modeling a filter chain; the lesson runs the case of an endpoint carrying no rule at all and shows that whether it is open or closed is decided by a single parameter given when the container is constructed, a difference that never appears in the source.
Published
In this lesson
- Runs an endpoint that carries an authorization annotation and shows that the decision comes from the annotation, compared against the caller's role
- Shows that an endpoint carrying no authorization annotation gets its decision from the container's default, and that the same source produces two different results under two different defaults
- Measures a hand-written check's decision by comparing it directly against the annotation-based decision
Contents
The last lesson of the previous topic measured how a transaction boundary opens in the source: an annotation placed the boundary, and a call the component made from within itself never saw that boundary at all — once the proxy dropped out, the annotation dropped out with it. This topic asks the same question for security, testing, and distributed setup; the first stop is authorization. Someone searching the source for who can reach an endpoint most often finds nothing at all: no check statement, no role comparison. The decision comes either from an annotation or from nowhere at all — from the IoC container’s own default. The concepts of authentication and authorization — who is who, how a permission is represented, the difference between a role and a right — were built in the Authentication and Authorization and the Identity, Access and Cryptography courses and are not repeated here; this lesson measures only which mechanism supplies the decision. K06’s question does not change for this topic either: did a call give rise to this behavior in the source, an annotation, or neither? A filter chain is modeled and three sources are separated: call, annotation, default. The third is the most dangerous, because the container’s default leaves no trace at all in the source; the same source text produces two different security postures under two different defaults, and this lesson shows that by running it.
The boundaries of this lesson are drawn from the start as well: the distinction between authentication (the caller proving who it is) and authorization (deciding what a proven identity may do) was built in the Authentication and Authorization course; this lesson takes the caller’s identity as an already-resolved role string and measures only the source of the authorization decision — how the identity was proven is the subject of the next lesson. There is no real socket, no real request body, no real session store; only the filter chain and the container that reads the decision are modeled.
The Decision the Filter Chain Applies
The modeled container is a small class that maps methods marked @Endpoint to a path name;
this is the same mechanism as the container in the Core Concepts topic — there, the annotations
that construct an object were measured, here it is the same container’s decision about which
rule a request passes through that is measured. When Gatekeeper is constructed it scans every
method of the target object once, mapping the ones carrying @Endpoint to a path name; this
scan happens once at construction time, not on every call. When a call arrives, a single-link
filter chain takes over: if the method carries an @Authorization mark, it compares the
caller’s role against the role in the mark; if there is no mark, it looks at the defaultOpen
field given when the container was constructed. There is no real network request, no real
session, no real server — only the chain itself and the reflection that reads the decision.
// Gatekeeper.java — small IoC container that passes a request through a filter chain and gives the authorization decision from either the source annotation or the container's default (helper source, no main)
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
@interface Endpoint { String path(); }
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
@interface Authorization { String role(); }
class Gatekeeper {
final Object target;
final boolean defaultOpen;
final Map<String, Method> endpoints = new LinkedHashMap<>();
Gatekeeper(Object target, boolean defaultOpen) {
this.target = target;
this.defaultOpen = defaultOpen;
for (Method m : target.getClass().getDeclaredMethods()) {
Endpoint e = m.getAnnotation(Endpoint.class);
if (e != null) endpoints.put(e.path(), m);
}
}
// the filter chain's single link: reads the annotation if present, otherwise falls back to the container's default.
boolean allow(String path, String callerRole) {
Method m = endpoints.get(path);
if (m == null) return false;
Authorization a = m.getAnnotation(Authorization.class);
if (a == null) return defaultOpen;
if (a.role().equals("everyone")) return true;
return a.role().equals(callerRole);
}
String call(String path, String callerRole) throws Exception {
if (!allow(path, callerRole)) return "blocked";
Method m = endpoints.get(path);
m.setAccessible(true);
return (String) m.invoke(target);
}
static void observe(String behavior, String source, Object result) {
System.out.printf("%-40s %-11s: %s%n", behavior, source, result);
}
}
class Endpoints {
@Endpoint(path = "/panel") @Authorization(role = "manager")
String panel() { return "panel-data"; }
@Endpoint(path = "/status")
String status() { return "running"; }
@Endpoint(path = "/version") @Authorization(role = "everyone")
String version() { return "version-3"; }
}
The source of the Endpoints class has no line that calls allow, nor any that compares
roles. The @Authorization(role = "manager") sitting above the panel method is only a mark;
the one who reads and applies it is Gatekeeper. The annotation types are marked
@Retention(RetentionPolicy.RUNTIME), because the decision is given by reflection at run time,
not by the compiler — an annotation stripped from the class path at compile time leaves
nothing for Gatekeeper to read; the target is ElementType.METHOD, so the mark can only be
placed on methods, not carried by the class itself or by fields. ST1. The filter chain’s
single link reads the authorization annotation and compares it against the caller’s role; if
there is no annotation, it falls back to the container’s own default. ST2. At an endpoint
carrying an authorization annotation, no call expression in the source gives rise to the
decision at all — the decision comes from the annotation.
// FilterDecision.java — at an endpoint with an authorization annotation, the decision comes from the filter chain, which compares the caller's role against the annotation
public class FilterDecision {
public static void main(String[] args) throws Exception {
Gatekeeper gatekeeper = new Gatekeeper(new Endpoints(), true);
Gatekeeper.observe("panel, caller manager", "annotation", gatekeeper.call("/panel", "manager"));
Gatekeeper.observe("panel, caller employee", "annotation", gatekeeper.call("/panel", "employee"));
}
}
panel, caller manager annotation : panel-data panel, caller employee annotation : blocked
The same gatekeeper object, the same call invocation, the same /panel path — the only
thing that changes is the role given as an argument. The call arriving with the manager role
reaches the method and returns its body; the call arriving with the employee role never runs
the method and gets the result blocked. The decision sits not in the body of the panel
method but in the mark above it, and the party that reads the mark is Gatekeeper itself; the
panel method itself knows neither the word manager nor asks who is calling. Without rewriting
the method, the same behavior can be opened to a different role by changing only the mark
above it — for example to role = "employee" — and this change never touches the body at all.
The path mapping is extracted once when Gatekeeper is constructed; call does not repeat the
reflection scan on every invocation, it looks at the already-extracted endpoints map. When an
unrecognized path is requested, allow returns false directly — and this too is not a call
but the container’s own rule: every path with no entry in the map is rejected without any
annotation being read at all. So there is a third default as well, but its direction is fixed
this time: an unknown path is closed under every condition, independent of the defaultOpen
field. This asymmetry between a known and an unknown path is also invisible in the source; it
can only be understood by looking at the contents of the endpoints map itself.
When No Rule Is Written: The Direction of the Default
The status method in the Endpoints class carries no authorization mark at all. This is the
case where someone reading the source finds nothing — and this is exactly why the most
restrictive measurement lies here. When allow finds no mark, it looks at the defaultOpen
field; this field is a constructor parameter given when Gatekeeper is constructed, a value
that appears nowhere in the source. The difference between the two setups is only this single
boolean value: no class is rewritten, no annotation is added.
// DefaultDirection.java — an endpoint whose source never changes produces two different results from a single parameter given when the container is constructed
public class DefaultDirection {
public static void main(String[] args) throws Exception {
Gatekeeper openGatekeeper = new Gatekeeper(new Endpoints(), true);
Gatekeeper closedGatekeeper = new Gatekeeper(new Endpoints(), false);
Gatekeeper.observe("status, open-setup", "default", openGatekeeper.call("/status", "guest"));
Gatekeeper.observe("status, closed-setup", "default", closedGatekeeper.call("/status", "guest"));
System.out.println("source unchanged: Endpoints.status() carries no authorization mark");
}
}
status, open-setup default : running status, closed-setup default : blocked source unchanged: Endpoints.status() carries no authorization mark
Endpoints.java did not change by a single character between the two runs; openGatekeeper
and closedGatekeeper are constructed from the same class. Even so, the same call with the
guest role returns data under one setup and is rejected under the other. ST3. At an
endpoint carrying no authorization annotation, the decision comes from the container’s own
default; the source text shows neither of these two directions. ST4. The same source,
without being recompiled, can produce two different security postures from only a constructor
parameter given when the container is constructed — this is the lesson’s limiting measurement.
An unmarked endpoint is neither secure nor insecure; which one it is can be understood not by
reading the source but by knowing which default the container was constructed with. This is the
lesson’s most important observation: calling the difference between the two endpoints by a
fixed adjective such as “open” or “closed” is misleading, because the adjective belongs not to
the source but to the setup decision.
This decision does not belong to the party that wrote the Endpoints class either; whoever
constructs Gatekeeper holds the default in their hands. In a class with many endpoints, a
single method for which the authorization annotation was forgotten stays silently reachable
under a default-open setup — there is no error, no warning in the source, because a forgotten
mark leaves no trace at all by definition. Under a default-closed setup, the same oversight
works in the opposite direction: every unmarked method becomes unreachable, and the omission is
noticed only by the caller’s observation of being “unexpectedly blocked.” Both directions fill
the same gap, but the result they fill it with is exactly opposite; which direction was chosen
is written once, on the line that constructs the container, and it covers the entire unmarked
surface at once.
If the direction of the default does not appear in the source, can an annotation override it?
The @Authorization(role = "everyone") above the version method attempts exactly that: not a
specific role name, but an open-door mark.
// ExplicitlyDeclared.java — an endpoint carrying an open-to-everyone mark passes through the annotation even when the container's default is closed
public class ExplicitlyDeclared {
public static void main(String[] args) throws Exception {
Gatekeeper closedGatekeeper = new Gatekeeper(new Endpoints(), false);
Gatekeeper.observe("version, closed-setup", "annotation", closedGatekeeper.call("/version", "guest"));
Gatekeeper.observe("status, closed-setup", "default", closedGatekeeper.call("/status", "guest"));
}
}
version, closed-setup annotation : version-3 status, closed-setup default : blocked
Two calls on the same closedGatekeeper object: version was declared open to everyone by an
annotation, so it overrides the default and passes; status carries no mark at all, so it
falls to the same container’s closed default. When an annotation exists, the default never
comes into play at all — the order reaches the default only if there is no annotation. This
shows the precedence between the two sources: the annotation is read first, and only if it is
absent does the flow fall back to the default; a rule written in the source wins independent of
the direction of the default. This order of precedence, too, sits not in the source but in the
body of the allow method itself — whoever wrote Gatekeeper decided in advance which source
gets asked first.
Manually Written Authorization Checks
A third source sits in a class separate from the previous two: ManualAuthorization, an
ordinary method that never enters the filter chain, carrying neither @Endpoint nor
@Authorization. Here the decision is given not by the container but by a check expressed
directly in the method’s own body.
// ManualAuthorization.java — the authorization decision comes not through the IoC container or an annotation, but directly from a check written in the source (helper source, no main)
class ManualAuthorization {
String sensitiveOperation(String callerRole) {
if (!"manager".equals(callerRole)) throw new SecurityException("unauthorized access");
return "sensitive-data";
}
String attempt(String callerRole) {
try { return sensitiveOperation(callerRole); }
catch (SecurityException e) { return e.getClass().getSimpleName(); }
}
}
// ManualCheck.java — called with the same two roles, showing that the decision comes from a condition in the source
public class ManualCheck {
public static void main(String[] args) {
ManualAuthorization manual = new ManualAuthorization();
Gatekeeper.observe("sensitiveOperation, caller manager", "call", manual.attempt("manager"));
Gatekeeper.observe("sensitiveOperation, caller employee", "call", manual.attempt("employee"));
}
}
sensitiveOperation, caller manager call : sensitive-data sensitiveOperation, caller employee call : SecurityException
The result looks the same as in the FilterDecision lesson — a manager passes, an employee is
rejected — but its source differs. ST5. A manually written check gives the decision
directly from the source; neither an annotation nor the container comes in between, the
decision comes from the comparison sitting in the first line of the sensitiveOperation
method. This third path lives in a class Gatekeeper never manages;
ManualAuthorization never registered with it, so neither the annotation nor the default
direction applies to it — the same boundary observed in the previous topic’s closing lesson
holds here as well: an object left outside the container receives none of its mechanisms.
The three separate sources shown by the three lessons sit side by side here:
| Source | Where the decision is made | Its example in this lesson |
|---|---|---|
| annotation | the @Authorization mark, read by Gatekeeper |
panel, version |
| default | defaultOpen, given when Gatekeeper is constructed |
status |
| call | the check expressed in the method’s own body | sensitiveOperation |
The same question of “manager or not” is answered in three different ways from three different
places, and someone who only reads the source — even seeing the annotations — sets out without
knowing which answer produces which behavior. For status specifically this lack of
information is even sharper: there is no mark to read there at all, the decision belongs
entirely to the moment the container is constructed. None of these three sources is “correct”
or “incorrect”; each is held in a different place, and the person reading the source needs to
know in advance which places to look — the measurement axis built through this course exists
precisely to make this need to know countable.
The separation also concerns scale: an annotation and a default cover an entire class or
container with a single decision, while a manual check covers only the method it is written
in. Ten methods like sensitiveOperation would repeat the same comparison ten times; the
mark-based approach removes this repetition but moves the decision from the source to the
container. This lesson took the caller’s identity as an already-resolved role string —
"manager", "employee", "guest" — and never asked where that string came from.
Summary
- At an endpoint carrying an authorization annotation, the filter chain reads the decision from the mark and compares it against the caller’s role; the source of this behavior is the annotation.
- At an endpoint carrying no authorization annotation, the decision comes from the container’s own default; the source text never shows this behavior at all.
- The same source, without being recompiled, can produce two different security postures from only a parameter given when the container is constructed — this is the limiting measurement.
- When an annotation is present, the default never comes into play at all; the order of precedence is first the annotation, then the default.
- A manually written check gives the decision directly from the source; because this method never registers with the container, neither the annotation nor the default direction applies to it.
Next Step
This lesson took the caller’s role as a ready-made string — who it was was already a known input. A real caller has to carry this information from somewhere, and this course does not keep a session on the server side. The next lesson models how this role and identity come not from state stored on the server, but from a token the caller brings along with every request.
To keep your progress and take notes, Log in
My notes
Log in to take notes.