The zero trust security model is primarily focused on preventing unauthorized access via enforcing dynamic and granular per-request access control at the resource level. Such access control system is enforced in Octelium via Policies. Each Policy has a set of one or more rules that usually share a similar functionality or purpose according to the Cluster administrators' requirements or needs. Each rule has:
- A condition that must match in order for the rule and subsequently the Policy as a whole to match.
- An effect that is either
ALLOW
orDENY
deciding the outcome of the rule if it matches.
Vigil is the identity-aware proxy (IAP) that implements the Service and sits between the Users and the protected resource intercepting the requests. Upon each request coming from the Users, Vigil determines whether the request should be allowed or denied by delegating the process of authentication and authorization to another component called Octovigil, the policy decision point (PDP), which in turn identifies the Session and its User and then starts collecting and evaluating all Policies required by that specific request in order to decide whether the request should be allowed or denied. Such required Policies are the ones defined in or attached to the request's Session, User, every Group of the User, the Service and the Namespace. Once all required Polices for the request are collected, Octovigil starts evaluating all the Policies rules as follows:
- Octovigil starts evaluating the
DENY
rules of all the Policies, if any rule matches, then the request is denied. - If no
DENY
rule matches, the Octovigil starts evaluating all theALLOW
rules, if anyALLOW
matches then the request is allowed. - If none of the
DENY
orALLOW
rules match, then the request is considered denied since no rule explicitly allows it.
Octelium provides you with an extremely flexible, multi-layered access control system in order to make the process of granting access as dynamic and as fine-grained as possible and at the same time keep it stay manageable and maintainable at any scale.
Octelium provides a dynamic context-aware, attribute-based access control system (ABAC) using policy-as-code. ABAC itself is widely considered as the successor to the classical role-based access control (RBAC) as ABAC enables policies to take much more dynamic and granular decisions that are not just based on roles and memberships, but also on the different attributes of the subject, the resource, as well as the request context which might include information about application-specific actions (e.g. HTTP request information, database commands, etc...), contextual information such as time of the day and geo-location, as well as information feeds collected from external sources such as SIEM and threat intelligence tools.
It is also worth noting that Octelium has no notion of a "superuser" or an "admin" User. There is simply no User, by default, who has all permissions to access all Services, including the Cluster's API Server, unless having been explicitly allowed by a Policy.
Request Context
Octelium exposes to you the entire current state of all information related to the request in order for you to freely design your policies as code as will be shown later. All such information are contained in a single data structure called RequestContext. RequestContext information can be mainly classified into:
- Subject/principal-based information (namely the Session, its User and all of the User's Groups)
- Object/resource-based information (namely the Service and its Namespace)
- The request specific information (e.g. application layer (i.e. L-7) information such as HTTP request method, headers, etc...) whenever available.
Policy Types
There are 2 types of Policies:
- Standalone Policies that are defined as typical Octelium resources in the Core API just like Services or Users. Standalone Policies are meant to be reusable and can be attached to one or more resources, namely Service, Namespace, User, Group or Session resources.
Here is an example of a standalone Policy that is attached to a Service:
1kind: Policy2metadata:3name: allow-all4spec:5rules:6- effect: ALLOW7condition:8matchAny: true9---10kind: Service11metadata:12name: example-svc13spec:14mode: HTTP15config:16upstream:17url: https://example.com18authorization:19# Now we attach the above Policy to the Service20policies: ["allow-all"]
It's important to understand that defining Standalone Policies is not enough to make them effective. They must be attached to a resource, such as a Service or a User, in order for the Policy to actually be applied for requests coming to that Service or from that User.
- Inline Policies that are defined inside a specific resource. Inline Policies are more useful when the rules written for a specific resource and where creating a separate, reusable, standalone Policy does not make much sense. Here is the equivalent example of the above standalone Policy when defined directly as an inline Policy inside a Service:
1kind: Service2metadata:3name: example-svc4spec:5mode: HTTP6config:7upstream:8url: https://example.com9authorization:10inlinePolicies:11- spec:12rules:13- effect: ALLOW14condition:15matchAny: true
Condition
Every Policy has a set of one or more rules that share a common purpose or functionality of the Policy. Each rule has an effect that either ALLOW
or DENY
and a corresponding condition that must match in order for the rule to match and trigger its effect decision.
A condition can be one of the following types: match
, matchAny
, all
, any
, not
, none
or opa
. As you will see below, main conditions types such as match
contain expressions written in Common Expression Language CEL. CEL is chosen not only for its performance but also for its extreme simplicity. Octelium exposes the RequestContext inside the ctx
map as will be shown in the examples below.
Here are some examples for the different condition types:
MatchAny
matchAny
is simply a boolean that matches anything. Here is an example:
1kind: Policy2metadata:3name: allow-all4spec:5rules:6- effect: ALLOW7condition:8matchAny: true
Match
match
is represented by a single conditional CEL expression. Here is an example:
1kind: Policy2metadata:3name: deny-contractors4spec:5rules:6# Deny when any User that belongs to the "contractors" Group7- effect: DENY8condition:9match: '"contractors" in ctx.user.spec.groups'
The CEL language is rich, performant and self-explanatory in most cases. Refer to this guide here to know more about the capabilities of CEL to write more dynamic and complex expressions.
Not
not
is the inversion of match
conditions. Here is an example:
1kind: Policy2metadata:3name: deny-workloads4spec:5rules:6# Allow when any User type is NOT "WORKLOAD"7- effect: ALLOW8condition:9not: ctx.user.spec.type == "WORKLOAD"
All
all
simply acts as a logical AND operator to one or more children conditions. In other words, an all
condition matches if all of its children conditions match. Here is an example:
1kind: Policy2metadata:3name: match-expression4spec:5rules:6# Allow when ALL such expressions match7- effect: ALLOW8condition:9all:10of:11- match: ctx.user.spec.type == "HUMAN"12- match: ctx.device.status.osType == "WINDOWS"13- match: ctx.session.status.type == "CLIENT"14- match: '"friends" in ctx.user.spec.groups'15- match: ctx.namespace.metadata.name == "production"
Any
any
simply acts as a logical OR operator to one or more children conditions. In other words, an any
condition matches if any of its children conditions match. Here is an example:
1kind: Policy2metadata:3name: match-expression4spec:5rules:6- effect: ALLOW7condition:8# Match when any of the following expressions match9any:10of:11- match: ctx.user.spec.type == "HUMAN" && "dev" in ctx.user.spec.groups12- match: ctx.user.spec.type == "WORKLOAD" && "ops" in ctx.user.spec.groups
None
none
simply acts as a logical NOR operator to one or more children conditions. In other words, an none
condition matches id none of its children conditions match. Here is an example:
1kind: Policy2metadata:3name: neither-dev-nor-ops4spec:5rules:6- effect: ALLOW7condition:8# Match when none of the following expressions match9none:10of:11- match: '"dev" in ctx.user.spec.groups'12- match: '"ops" in ctx.user.spec.groups'
OPA
opa
Octelium also enables you to define your condition as an OPA (Open Policy Agent) Rego script. OPA enables you to define complex policies as code that might be too complicated to be described in CEL Conditions. Note that in the case of OPA scripts, the ctx
as well as attrs
fields are now children of the input
field. Also the result must be boolean and set to the match
OPA "rule". Here is an example:
1kind: Policy2metadata:3name: opa-example4spec:5rules:6- effect: ALLOW7condition:8opa:9inline: |10package octelium.condition1112default match = false1314match {15input.ctx.user.spec.groups[_] == "group1"16}1718match {19input.ctx.user.metadata.name != "usr1"20input.ctx.user.spec.groups[_] == "group2"21startsWith(input.ctx.request.http.path, "/path1")22input.ctx.request.http.method == "GET"23}
Nesting Conditions
all
, any
and none
conditions can parent any condition type including further all
, any
or none
conditions. This gives you the ability to design more complex rules in a readable, performant and maintainable way. Here is an example:
1kind: Policy2metadata:3name: match-expression4spec:5rules:6# Allow when ALL such expressions match7- effect: ALLOW8condition:9all:10of:11- match: ctx.user.spec.type == "HUMAN"12- any:13of:14- match: '"friends" in ctx.user.spec.groups'15- match: '"also-friends" in ctx.user.spec.groups'16- none:17of:18- match: '"bad-friends" in ctx.user.spec.groups'19- match: '"former-friends" in ctx.user.spec.groups'20
Policy Priority
So far, we have assumed that all rules have the same priority across all request's Policies to be evaluated. That assumption made ordering the rules irrelevant since any DENY
rule that matches would just deny the request regardless of there is one or more ALLOW
rules in any Policy that also matches. To facilitate easier Policy management and provide even richer and more dynamic control, Octelium also provides a priority level integer for each rule priority that is taken into consideration globally across all Policies. In other words, a rule's priority enables an ALLOW
rule of a higher priority (i.e. lower number) rule to override any DENY
rule that matches but with a lower priority (i.e. higher number).Simply the lower the number, the higher the priority. The priority level can be chosen from within range from -16 (i.e. highest priority) to 16 (i.e. lowest priority) and by default a rule has a priority of zero.
Here is an example:
1kind: Policy2metadata:3name: allow-all4spec:5rules:6- effect: ALLOW7priority: -18condition:9matchAny: true10- effect: DENY11condition:12matchAny: true
And here is another example:
1kind: Policy2metadata:3name: allow-management-prod4spec:5rules:6- condition:7all:8of:9- match: ctx.service.metadata.name == "api.production"10- match: '"management" in ctx.user.spec.groups'11effect: ALLOW12priority: 113- condition:14all:15of:16- match: ctx.service.metadata.name == "api.production"17effect: DENY18priority: 2
Enforcement Rules
Using enforcement rules is optional, enforcement rules can be seen as "pre-conditions" that must be met in order for the Policy to be evaluated. You can think of Enforcement rules as rules for the Policy itself as to whether it should be enforced/evaluated or ignored altogether. The goal of enforcement rules is to make Policy rules more concise, readable and and potentially more performant since in many cases such rules may include shared conditions which can be taken out of the rules and used as pre-conditions for the Policy itself. For example, you might want to enforce a Policy only under certain pre-conditions (e.g. enforcing a Policy only for certain time of the day, for a certain set of Namespaces, Services or Users, etc...). While you can definitely do that inside the rules' conditions, you will probably have to reuse such logic in all of your rules that share the same Policy.
Let's see this example for a Service that allows any User who belongs to the friends
Group except for any HTTP path that starts with /admin
.
1kind: Service2metadata:3name: dashboard4spec:5authorization:6- policies: ["p-dashboard"]78---9kind: Policy10metadata:11name: p-dashboard12spec:13rules:14- condition:15all:16of:17- match: '"friends" in ctx.user.spec.groups'18- match: ctx.request.http.path.startsWith("/admin")19effect: DENY20- condition:21match: '"friends" in ctx.user.spec.groups'22effect: ALLOW
You can notice that we had to use the expression "friends" in ctx.user.spec.groups
twice since this Policy was supposed to be confined to Users belonging to the friends
Group. We could tune this Policy in order to be more readable and performant by separating this precondition as an enforcement rule as follow:
1kind: Policy2metadata:3name: p-dashboard4spec:5enforcementRules:6- effect: IGNORE7condition:8not: '"friends" in ctx.user.spec.groups'9rules:10- effect: DENY11condition:12match: ctx.request.http.path.startsWith("/admin")13- effect: ALLOW14condition:15matchAny: true
As you can see from the example above, the only difference in syntax between Enforcement rules and ordinary access rules is that the effect of enforcement rules is either IGNORE
which forces the Policy to be ignored or ENFORCE
which forces the Policy to be evaluated. If there are no enforcement rule that matches or if the list of enforcement rules is empty then the default behavior is to ENFORCE
the Policy. In other words, ENFORCE
enforcement rules simply always override IGNORE
rules in the same way that DENY
rules override ALLOW
rules.
Attributes
In some cases, you might want to define Policy-specific structured data that can be (re)used inside the Policy rules. While not required, we strongly recommend that your keys are defined in camel case (e.g. userType
) as opposed to snake case (e.g. user_type
) to conform with the rest of Octelium API design convention. Here is an example:
1kind: Policy2metadata:3name: http-dangerous-methods4spec:5attrs:6deniedGroups: ["grp-1", "grp-2"]7deniedMethods: ["POST", "PUT", "DELETE"]8userType: "HUMAN"910rules:11- effect: DENY12condition:13match: ctx.user.spec.groups.hasAny(attrs.deniedGroups)14- effect: DENY15condition:16all:17of:18- match: ctx.user.spec.groups.hasAny(["grp-3"])19- match: ctx.request.http.method in attrs.deniedMethods20- effect: DENY21condition:22all:23of:24- not: ctx.user.spec.type == attrs.userType25- match: ctx.request.http.path.startsWith("/admin")26- effect: ALLOW27condition:28matchAny: true
Disabling a Policy
In some cases you might need to disable a certain Policy( e.g. for diagnostic reasons) instead of having to totally remove it. You can disable a Policy simply as follows:
1kind: Policy2metadata:3name: allow-all4spec:5isDisabled: true6rules:7- effect: ALLOW8condition:9matchAny: true
Global Policies
As we have seen so far, all Polices had to be attached to resources such as Users or Services in order to be applied to a relevant request. Moreover, Octelium also lets you to define global Polices that are always triggered across all the Cluster's requests. Such global Polices, as well as inline Polices, are defined in the ClusterConfig as follows:
1kind: ClusterConfig2metadata:3name: cluster-config4spec:5authorization:6policies: ["pol-1", "pol-2"]7inlinePolicies:8- spec:9rules:10- effect: DENY11condition:12match: '"junior" in ctx.user.spec.groups && ctx.namespace.metadata.name == "production"'
Extending Access Control
One of the main tenets of zero trust is to grant access based on dynamically evaluated trust on a per-request basis where such trust should be evaluated from as many information sources as possible (e.g. IAM platforms, SIEM tools, threat intelligence tools, incident alerting and on-call management tools, etc...).
With Octelium, you can extend access control to include arbitrary attributes to a various Cluster resources via the attrs
field that can contain any arbitrary data. The following Resources support the attrs
field: User, Group, Service and Namespace. Here is an example for a User:
1kind: User2metadata:3name: john4spec:5type: HUMAN67attrs:8pagerDuty:9onCall: true10azureAD:11isAdmin: true12anotherTool:13key1: val114key2: 10015key3: true16key4:17subKey1: subVal1
Now you can enforce such information in your Policies as follows:
1kind: Policy2metadata:3name: allow-on-call4spec:5rules:6- effect: ALLOW7condition:8match: ctx.user.spec.attrs.pagerDuty.onCall
As stated above in Polices's attributes, we strongly recommend that your keys are defined in camel case (e.g. azureAD
) as opposed to snake case (e.g. azure_ad
) to conform with the rest of Octelium API design convention.