teleport/rfd/0078-login-rules.md

21 KiB

authors state
Nic Klaassen (nic@goteleport.com) implemented

RFD 78 - Login Rules

Required Approvers

  • @klizhentas

What

A new feature which allows admins to filter, transform, and extend incoming SAML assertions and OIDC claims from their identity provider during user login before they are embedded in the traits of their Teleport user's certificates.

Why

Teleport admins often do not have control over their company's SSO provider and which claims will be provided to Teleport. All of these claims will be embedded in each user's Teleport certificate as traits.

SSO providers can include hundreds to thousands of claims which are not necessary or used by Teleport at all, unecessarily bloating the certificate size of all users. This can adversly impact latency and throughput of Teleport operations, and in an extreme case could reach a limit which would render Teleport unusable with that identity provider. Login rules will allow the Teleport admin to filter out claims which are unecessary before they make it into the Teleport certificate.

Admins often wish to extend an incoming claim with extra values which make sense for their Teleport deployment. For example, users within the splunk group should also be added to the dbs group within Teleport so that they can access databases. Login rules will allow the Teleport admins to modify existing traits and add new ones to solve these problems.

If Teleport admins cannot add or change the claims provided by their SSO provider, making simple rules like this can be extremely cumbersome or impossible within teleport. There are currently teleport users with deployments which automatically generate hundreds to thousands of roles to get around these limitations.

Details

Example login rule spec:

kind: login_rule
version: v1
metadata:
  name: example
spec:
  # priority can be used to order the evaluation of multiple login rules within
  # a cluster.
  #
  # Login rules with lower numbered priorities will be applied first, followed
  # by rules with priorities in increasing order. In case of a tie, login rules
  # with the same priority will be ordered by a lexicographical sort of their
  # names.
  priority: 0

  # traits_map will determine the traits of all users who log in to this cluster.
  #
  # This is a YAML map where the key must be a static string which will be the
  # final trait key, and the value is a list of predicate expressions which each
  # must return a set of strings. The final trait will be set to the union of
  # the resulting string sets of all predicate expressions for that trait key.
  #
  # traits_map must contain the complete set of desired traits, any external
  # traits not found here will not be included in the user's certificates.
  traits_map:
    # keep an external trait, unmodified
    logins:
      - external.logins

    # rename an external trait
    db_logins:
      - external.Database_Usernames

    # merge external traits
    kube_groups:
      - external.groups
      - external.kubernetes_groups

    # transform an external trait
    apps:
      - 'lower(external.apps)'

    # extend an external trait
    windows_logins:
      - external.windows_logins
      - "bill"

    # conditionally extend an external trait
    groups:
      - 'ifelse(external.groups.contains("splunk"), external.groups.add("dbs"), external.groups)'

    # static string values
    tags:
      # if the "expression" is a static string or returns a single strings, this
      # will be automatically treated as a set of size 1.
      - "teleport"
      - "access"

  # traits_expression is a single predicate expression which must return a dict which will
  # set the user's traits during login.
  #
  # The dict must always have keys of type string and values of type set of
  # strings, or an error will be returned when the expression is evaluated.
  #
  # traits_expression is an alternative to traits_map, only one or the other
  # can be specified, never both in the same login_rule.
  #
  # traits_expression: >
  #   external.remove("irrelevant", "internal", "tags")
  #     .put("groups", 
  #       ifelse(external.groups.contains("splunk"),
  #         external.groups.add("dbs"),
  #         external.groups))
  #     .put("logins",
  #       set(
  #         "ubuntu",
  #         ifelse(external.groups.contains("admins"), "root", ""),
  #         ifelse(external.organization.contains("teleport"), "teleporters", "external"))
  #       .remove(""))

  # eventually login_rules could also return the desired teleport roles for the
  # user to allow custom logic, but this will be out of scope for the initial
  # RFD and implementation.
  # roles: >
  #   set("ssh-users", "db-users")

Why both traits_map and traits_expression

traits_expression is a way for users with complex requirements to express their desired traits. It has the ability to modify the original external traits and operate on the keys with logical expressions, rather than only have a map of desired traits with static keys.

The traits_expression could also be generated by an external tool such as an upcoming project where traits could be modelled in an external language with support for theorom proving and formal verification.

The traits_map is a more approachable syntax for Teleport admins. It is mostly YAML with some reference to external traits which should be familiar if they have written templated Teleport roles. There are some predicate helper functions available like ifelse and lower which can be used to write reasonably powerful expressions without requiring the user to grok dict and set manipulation in predicate.

traits_map:
  key1: 
    - expr1
    - expr2
  key2:
    - expr3

Is really just another way of writing

traits_expression: >
  dict(
    pair(key1, union(expr1, expr2)),
    pair(key2, union(expr3)))  

Context available in predicate expressions

For all predicate expressions in the login rule (values of traits_map, or the traits_expression) the full dict of the users incoming external traits are available under the external identifier. This is similar to how traits are accessed in role templates, you can get trait example with either external["example"] or external.example.

If there is only a single login rule in the cluster, the traits available will be those provided by the SSO connector used to log in, and are the traits the user would otherwise have been given if the login rule were not in place.

If the cluster includes multiple login rules, they will be sorted in increasing order of their priority field (ties will be deterministically broken by a secondary sort by the rule name). The traits input to each successive login rule will be the output of the previous login rule.

Predicate Helper Functions

A set of helper functions to be used in the predicate expressions will be included. Part of the reasoning for using the predicate language is so that it will be extensible. As the need arises, we can always add new helper function.

The predicate parser is based on the Go language parser, so Go keywords which may be better names (such as if or select) must unfortunately be avoided.

ifelse(cond, value_if_true, value_if_false)

ifelse(cond, value_if_true, value_if_false) returns value_if_true if cond evaluates to true, else it returns value_if_false. ifelse(set("a").contains("a"), set("b", "c"), set()) returns ("b", "c").

Note: this would ideally be called just if but ast.ParseExpr used by our parser does not accept Go keywords which are normally part of statements rather than expressions.

choose(...options)

choose(...options) returns the value of the first option for which the condition evaluates to true.

choose(option(false, set("a", "b")), option(true, set("c", "d"))) returns ("c", "d")

choose(option(set("a").contains("b"), "foo"), option(set("a").contains("a"), "bar")) returns "bar".

Use an option with the condition hardcoded to true to set a default value.

choose(option(set("a").contains("b"), "foo"), option(true, "default")) returns "default".

The same could always be accomplished with a series of ifelse expressions, but with the function syntax this would require deep nesting when there are many options.

Note: this would ideally be called select(...case) but ast.ParseExpr used by our parser does not accept Go keywords which are normally part of statements rather than expressions.

strings.replaceall(input, match, replacement)

Finds all literal string matches of match in input, and replaces them with replacement. strings.replaceall("user-nic", "-", "_") returns "user_nic". input can be a string or a set of strings, in which case the replacement will be applied to all strings in the set.

strings.upper(input)

strings.upper(input) returns a copy of the input string converted to uppercase. strings.upper("ExAmPlE") returns "EXAMPLE". input can be a string or a set of strings, in which case all strings in the set will be converted to uppercase.

strings.lower(input)

strings.lower(input) returns a copy of the input string converted to lowercase. strings.lower("ExAmPlE") returns "example". input can be a string or a set of strings, in which case all strings in the set will be converted to lowercase.

set(...items)

set() returns a set including all its arguments. Duplicates are not possible and will be filtered out on creation of the set if they are passed in. All items must be strings.

set.contains(value)

set.contains(value) returns true if any item in the set is an exact match for value, else it returns false. set("a", "b").contains("b") returns true.

set.add(...values)

set.add(...values) returns a copy of the set with values added. All values must be strings. set("a", "b").add("c").add("d", "e") returns ("a", "b", "c", "d", "e").

set.remove(...values)

set.remove(...values) returns a copy of the set with values removed. set("a", "b", "c", "d").remove("d").remove("c", "b") returns ("a").

union(...sets)

union(...sets) returns a new set which holds the union of all given sets.

union(set("a", b"), set("c")) returns ("a", "b", "c")

dict(...pairs)

dict() returns a dictionary with keys of type string and values of type set.

dict(
  pair("fruits", set("apple", "banana")),
  pair("vegetables", set("asparagus", "brocolli")),
)

returns

{
  "fruits": ("apple", "banana"),
  "vegetables": ("asparagus", "brocolli"),
}

Arguments must be pairs, the first element will be the key, and the second element will be the value. The key must be a string and the value must be a set (of strings).

dict.add_values(key, ...values)

dict.add_values(key, ...values) returns a copy of the dict with the given values added to the set at dict[key]. If dict[key] is empty or it does not exist, it will be added with values as its only elements.

dict(
  pair("fruits", set("apple")),
).add_values("fruits", "banana").add_values("vegetables", "asparagus", "brocolli")

returns

{
  "fruits": ("apple", "banana"),
  "vegetables": ("asparagus", "brocolli"),
}

dict.remove(...keys)

dict.remove(...keys) returns a copy of the dict with the given keys removed.

dict(
  pair("fruits", set("apple", "banana")),
  pair("vegetables", set("asparagus", "brocolli")),
).remove("vegetables")

returns

{
  "fruits": ("apple", "banana"),
}

dict.put(key, value)

dict.put(key, value) returns a copy of the dict with the given key set to value. Dictionary keys are always strings and the value must always be a set, else an error will be returned when the expression is evaluated.

dict(
  pair("fruits", set("apple", "banana")),
  pair("vegetables", set("asparagus", "brocolli")),
).put("vegetables", set("carrot")).put("trees", set("aspen"))

returns

{
  "fruits": ("apple", "banana"),
  "vegetables": ("carrot"),
  "trees": ("aspen"),
}

Modifications to predicate

The predicate language currently does not support "methods" on objects such as dict.remove or set.contains as described above. We will need to add support for these in our gravitational/predicate fork.

^ Update: method support has been implemented here, remaining changes can all happen in teleport.

Using transformed traits in roles

Login rules transparently set the user's traits during login. To the rest of the cluster, including role templates, they will appear identical to normal traits which are typically reference by {{external.<trait_name>}}.

When login rules will be parsed and evaluated

Login rules will be parsed and evaluated during each SSO user login. This will occur after the SSO provider has returned its assertions/claims, and before Teleport maps these to internal Teleport roles via attributes_to_roles or claims_to_roles so that transformed traits can be used for this mapping.

An eventual extension to login rules could also return the desired set of Teleport roles which the user should have.

During login, the auth server will load all login_rule resources in the cluster, sort them by priority and name, and apply all of them in order.

Local users

Login rules will not apply to local users for the initial release of login rules. One technical reason for this is that the user's static traits are held in its User resource, and these are sometimes accessed by teleport subsystems to determine the traits of various users. If these were different than the dynamic traits the user would get on login it would create inconsistencies.

Trusted clusters

Since login rules will be evaluated during login and the resulting traits will be embedded in the user's certificates, they only need to be created in the root cluster and the transformed traits will be visible and usable in all leaf clusters.

Creating and modifying login rules

Login rules will be a new backend resource login_rule.

They should be written in a YAML file and created by the usual means of tctl create resource.yaml. tctl get login_rule/example can be used to fetch the current resource, and tctl rm login_rule/example can be used to delete the resource.

Validating and testing login rules

A new tctl command will be introduced, which can be used to experiment with login rules and test what their effect will be before deploying them to your cluster.

$ tctl test login_rule \
  --resource-file login_rule1.yaml \
  --resource-file login_rule2.yaml \
  --load-from-cluster \
  <<< '{"groups": ["splunk"], "email": "nic@goteleport.com", "username": "nklaassen"}'

You can load multiple yaml files with --resource-file resource.yaml, optionally load existing login rules from the cluster with --load-from-cluster, and provide a set of input traits to test with.

The command will report any syntax errors, and will print the output traits for the given input.

Protobuf Definitions

The login_rule resource and associated CRUD RPCs will be added to a new package teleport/loginrule/v1.

// loginrule.proto
...

// LoginRule is a resource to configure rules and logic which should run during
// Teleport user login.
message LoginRule {
  // Metadata is resource metadata.
  types.Metadata metadata = 1;

  // Version is the resource version of this login rule. Initially "v1" is
  // supported.
  string version = 2;

  // Priority is the priority of the login rule relative to other login rules
  // in the same cluster. Login rules with a lower numbered priority will be
  // evaluated first.
  int32 priority = 3;

  // TraitsMap is a map of trait keys to lists of predicate expressions which
  // should evaluate to the desired values for that trait.
  map<string, wrappers.StringValues> traits_map = 4;

  // TraitsExpression is a predicate expression which should return the
  // desired traits for the user upon login.
  string traits_expression = 5;
}
// loginrule_service.proto
...


// LoginRuleService provides CRUD methods for the LoginRule resource.
service LoginRuleService {
  // CreateLoginRule creates a login rule if one with the same name does not
  // already exist, else it returns an error.
  // (RFD note) Used for: tctl create rule.yaml
  rpc CreateLoginRule(CreateLoginRuleRequest) returns (LoginRule);

  // UpsertLoginRule creates a login rule if one with the same name does not
  // already exist, else it replaces the existing login rule.
  // (RFD note) Used for: tctl create -f rule.yaml
  rpc UpsertLoginRule(UpsertLoginRuleRequest) returns (LoginRule);

  // GetLoginRule retrieves a login rule described by the given request.
  rpc GetLoginRule(GetLoginRuleRequest) returns (LoginRule);

  // ListLoginRules lists all login rules.
  rpc ListLoginRules(ListLoginRulesRequest) returns (ListLoginRulesResponse);

  // DeleteLoginRule deletes an existing login rule.
  rpc DeleteLoginRule(DeleteLoginRuleRequest) returns (google.protobuf.Empty);
}

// CreateLoginRuleRequest is a request to create a login rule.
message CreateLoginRuleRequest {
  // LoginRule is the login rule to be created.
  LoginRule login_rule = 1;
}

// UpsertLoginRuleRequest is a request to upsert a login rule.
message UpsertLoginRuleRequest {
  // LoginRule is the login rule to be created.
  LoginRule login_rule = 1;
}

// GetLoginRuleRequest is a request to get a single login rule.
message GetLoginRuleRequest {
  // Name is the name of the login rule to get.
  string name = 1;
}

// ListLoginRulesRequest is a paginated request to list all login rules.
message ListLoginRulesRequest {
  // PageSize is The maximum number of login rules to return in a single
  // reponse.
  int32 page_size = 1;

  // PageToken is the NextPageToken value returned from a previous
  // ListLoginRules request, if any.
  string page_token = 2;
}

// ListLoginRulesResponse is a paginated response to a ListLoginRulesRequest.
message ListLoginRulesResponse {
  // LoginRules is the list of login rules.
  repeated LoginRule login_rules = 1;

  // NextPageToken is a token to retrieve the next page of results, or empty
  // if there are no more results.
  string next_page_token = 2;
}

// DeleteLoginRuleRequest is a request to delete a login rule.
message DeleteLoginRuleRequest {
  // Name is the name of the login rule to delete.
  string name = 1;
}

Note: there will be a separate Go struct type wrapping the LoginRule type which can be marshalled to and from YAML by tctl and includes the requisite ResourceHeader.

Resource RBAC

The new resource login_rule will support the standard RBAC verbs list, create, read, update, and delete. These will all be added to the preset editor role for new and existing clusters.

These can be defined in a role like

kind: role
version: v5
metadata:
  name: example
spec:
  allow:
    rules:
      - resources: [login_rule]
        verbs: [list, create, read, update, delete]

Future Work

The existing claims_to_roles and attributes_to_roles in our SAML and OIDC connectors offer only a simple static mapping of traits to roles. This could also be factored out of the connecter specifications into the login rule in order to leverage the predicate language to create more powerful expressions to determine which roles a user should receive. This will give us a complete system for solving the problem of incomplete data coming from identity providers.

This could use a new field in the currenlty proposed login rule yaml spec. We would need to figure out how to merge roles coming from multiple login rules and from SAML and OIDC connectors before adding this.

Extra Examples

Set a trait to a static list of values defined per group

kind: login_rule
version: v1
metadata:
  name: example
spec:
  priority: 0
  traits_expression: >
    external.put("allow-env",
      choose(
        option(external.group.contains("dev"), set("dev", "staging")),
        option(external.group.contains("qa"), set("qa", "staging")),
        option(external.group.contains("admin"), set("dev", "qa", "staging", "prod")),
        option(true, set()))    

Use only specific traits provided by the OIDC/SAML provider

To only keep the groups and email traits, with their original values:

kind: login_rule
version: v1
metadata:
  name: example
spec:
  priority: 0
  traits_expression: >
    dict(
      pair("groups", external.groups),
      pair("email", external.email))    

Remove a specific trait

To remove a specific trait and keep the rest:

kind: login_rule
version: v1
metadata:
  name: example
spec:
  priority: 0
  traits_expression: >
    external.remove("big-trait")    

Extend a specific trait with extra values

kind: login_rule
version: v1
metadata:
  name: example
spec:
  priority: 0
  traits_expression: >
    external.add_values("logins", "ubuntu", "ec2-user")    

Use the output of 1 login rule in another rule

kind: login_rule
version: v1
metadata:
  name: set_groups
spec:
  priority: 0
  traits_expression: >
    external.put("groups",
      ifelse(external.groups.contains("admins"),
        external["groups"].add("superusers"),
        external["groups"]))    
---
kind: login_rule
version: v1
metadata:
  name: set_logins
spec:
  priority: 1
  traits_expression: >
    external.put("logins",
      ifelse(external.groups.contains("superusers"),
        external["logins"].add("root"),
        external["logins"]))