Skip to content

Commit

Permalink
Add a deploy command & improve expression translation (#17)
Browse files Browse the repository at this point in the history
* better expression translation

* remove uneeded expr

* even better expression trans

* translate nots

* string set map translation

* translate label expressions

* correctly translate nonliterals inside of set equalities

* remove intermediary array

* add deploy command

* sudo option for predicate deploy

* fix stdin syntax

* comments + more expression translation code

* remove unused policy bit

* fill in scopes for access request & review

* map string set equals correctly

* format

* implement translation for basic logical operators and types

* implement translation for concat + split

* implement translation of further primitives

* reformat

* add select translation support for policy mapping

* merge identical branches

* finish adding translation code

* (feedback): group scopes as subfields

* resolve isort conflict

* replace explicit scope field with decorator
  • Loading branch information
xacrimon authored Oct 11, 2022
1 parent f666332 commit 028a938
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 29 deletions.
20 changes: 20 additions & 0 deletions predicate/cli/__main__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import subprocess
from runpy import run_path
from types import FunctionType

Expand Down Expand Up @@ -80,6 +81,25 @@ def export(policy_file):
click.echo(serialized)


@main.command()
@click.argument("policy-file")
@click.option("--sudo", "-s", is_flag=True)
def deploy(policy_file, sudo):
click.echo("parsing policy...")
module = run_path(policy_file, env)
policy = module["Teleport"].p
click.echo("translating policy...")
obj = policy.export()
serialized = yaml.dump(obj)
click.echo("deploying policy...")
args = ["tctl", "create", "-f"]
if sudo:
args.insert(0, "sudo")

subprocess.run(args, text=True, input=serialized, check=True)
click.echo(f'policy deployed as resource "{policy.name}"')


@main.command()
@click.argument("policy-file")
def test(policy_file):
Expand Down
33 changes: 21 additions & 12 deletions predicate/examples/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,36 @@ class Teleport:
name="access",
loud=False,
allow=Rules(
Node((Node.login == User.name)),
Node(
((Node.login == User.name) & (User.name != "root"))
| (User.traits["team"] == ("admins",))
),
),
options=OptionsSet(Options((Options.max_session_ttl < Duration.new(hours=10)))),
deny=Rules(
Node((Node.login == "mike")),
Node(
(Node.login == "mike")
| (Node.login == "jester")
| (Node.labels["env"] == "prod")
),
),
)

def test_access(self):
# Alice will be able to login to any machine as herself
ret, _ = self.p.check(Node((Node.login == "alice") & (User.name == "alice")))
ret, _ = self.p.check(
Node(
(Node.login == "alice")
& (User.name == "alice")
& (Node.labels["env"] == "dev")
)
)
assert ret is True, "Alice can login with her user to any node"

# We can verify that a strong invariant holds:
# Unless a username is root, a user can not access a server as
# root. This creates a problem though, can we deny access as root
# altogether?
ret, _ = self.p.check(Node((Node.login == "root") & (User.name != "root")))
assert (
ret is False
), "This role does not allow access as root unless a user name is root"

# No one is permitted to login as mike
ret, _ = self.p.query(Node((Node.login == "mike")))
assert ret is False, "This role does not allow access as mike"

# No one is permitted to login as jester
ret, _ = self.p.query(Node((Node.login == "jester")))
assert ret is False, "This role does not allow access as jester"
3 changes: 3 additions & 0 deletions predicate/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ source = "."
extra-requirements = "types-requests"
use-mypy = true

[tool.isort]
profile = "black"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
130 changes: 113 additions & 17 deletions predicate/solver/teleport.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
from . import ast
from .errors import ParameterError

def scoped(cls):
cls.scope = cls.__name__.lower()
return cls

class Options(ast.Predicate):
"""
Expand Down Expand Up @@ -55,6 +58,7 @@ def collect_like(self, other: ast.Predicate):
]


@scoped
class Node(ast.Predicate):
"""
Node is SSH node
Expand Down Expand Up @@ -179,15 +183,15 @@ class RequestPolicy:
# denials is a list of recorded approvals for policy
denials = ast.StringSetMap("policy.denials")


@scoped
class Request(ast.Predicate):
def __init__(self, expr):
ast.Predicate.__init__(self, expr)

def traverse(self):
return self.expr.traverse()


@scoped
class Review(ast.Predicate):
def __init__(self, expr):
ast.Predicate.__init__(self, expr)
Expand All @@ -205,22 +209,102 @@ def collect_like(self, other: ast.Predicate):
return [r for r in self.rules if r.__class__ == other.__class__]


# TODO: not really sure how I want to structure this logic
# as I'd like to keep the logic for non-teleport specific ast elements close to their
# definitions, but I am not sure if this is doable without a lot of complexity as the output
# format is tied to whatever builds on top of the general ast
# this might do for now
def transform_expr(predicate):
# t_expr transforms a predicate-lang expression into a Teleport predicate expression which can be evaluated.
def t_expr(predicate):
if isinstance(predicate, ast.Predicate):
return transform_expr(predicate.expr)
return t_expr(predicate.expr)
elif isinstance(predicate, ast.Eq):
return f"({transform_expr(predicate.L)} == {transform_expr(predicate.R)})"
elif isinstance(predicate, ast.String):
return f"({t_expr(predicate.L)} == {t_expr(predicate.R)})"
elif isinstance(predicate, ast.Or):
return f"({t_expr(predicate.L)} || {t_expr(predicate.R)})"
elif isinstance(predicate, ast.And):
return f"({t_expr(predicate.L)} && {t_expr(predicate.R)})"
elif isinstance(predicate, ast.Xor):
return f"({t_expr(predicate.L)} ^ {t_expr(predicate.R)})"
elif isinstance(predicate, ast.Not):
return f"(!{t_expr(predicate.V)})"
elif isinstance(predicate, ast.Lt):
return f"({t_expr(predicate.L)} < {t_expr(predicate.R)})"
elif isinstance(predicate, ast.Gt):
return f"({t_expr(predicate.L)} > {t_expr(predicate.R)})"
elif isinstance(predicate, ast.MapIndex):
return f"{predicate.m.name}[{t_expr(predicate.key)}]"
elif isinstance(
predicate,
(
ast.String,
ast.Duration,
ast.StringList,
ast.StringEnum,
ast.Bool,
ast.Int,
ast.StringSetMap,
),
):
return predicate.name
elif isinstance(predicate, ast.StringLiteral):
return f'"{predicate.V}"'
elif isinstance(predicate, str):
return f'"{predicate}"'
elif isinstance(predicate, tuple):
return f"[{', '.join(t_expr(p) for p in predicate)}]"
elif isinstance(predicate, (ast.BoolLiteral, ast.IntLiteral, ast.DurationLiteral)):
return f"{predicate.V}"
elif isinstance(predicate, ast.Concat):
return f"({t_expr(predicate.L)} + {t_expr(predicate.R)})"
elif isinstance(predicate, ast.Split):
return f"split({t_expr(predicate.val)}, {t_expr(predicate.sep)})"
elif isinstance(predicate, ast.StringTuple):
return f"[{', '.join(t_expr(p) for p in predicate.vals)}]"
elif isinstance(predicate, ast.Upper):
return f"upper({t_expr(predicate.val)})"
elif isinstance(predicate, ast.Lower):
return f"lower({t_expr(predicate.val)})"
elif isinstance(
predicate,
(ast.StringListContains, ast.IterableContains, ast.StringSetMapIndexContains),
):
return f"contains({t_expr(predicate.E)}, {t_expr(predicate.V)})"
elif isinstance(predicate, ast.StringListFirst):
return f"first({t_expr(predicate.E)})"
elif isinstance(predicate, (ast.StringListAdd, ast.StringSetMapIndexAdd)):
return f"add({t_expr(predicate.E)}, {t_expr(predicate.V)})"
elif isinstance(predicate, (ast.StringListEquals, ast.StringSetMapIndexEquals)):
return f"equals({t_expr(predicate.E)}, {t_expr(predicate.V)})"
elif isinstance(
predicate, (ast.Replace, ast.StringListReplace, ast.StringSetMapIndexReplace)
):
return f"replace({t_expr(predicate.val)}, {t_expr(predicate.src)}, {t_expr(predicate.dst)})"
elif isinstance(predicate, ast.RegexConstraint):
return f"regex({t_expr(predicate.expr)})"
elif isinstance(predicate, ast.RegexTuple):
return f"[{', '.join(t_expr(p) for p in predicate.vals)}]"
elif isinstance(predicate, (ast.Matches, ast.IterableMatches)):
return f"matches({t_expr(predicate.E)}, {t_expr(predicate.V)})"
elif isinstance(
predicate, (ast.StringListContainsRegex, ast.StringSetMapIndexContainsRegex)
):
return f"contains_regex({t_expr(predicate.E)}, {t_expr(predicate.V)})"
elif isinstance(predicate, ast.If):
return f"if({t_expr(predicate.cond)}, {t_expr(predicate.on_true)}, {t_expr(predicate.on_false)})"
elif isinstance(predicate, ast.Select):
return f"select([{', '.join(t_expr(p) for p in predicate.cases)}], {t_expr(predicate.default)})"
elif isinstance(predicate, ast.Case):
return f"case({t_expr(predicate.when)}, {t_expr(predicate.then)})"
elif isinstance(predicate, ast.Default):
return f"default({t_expr(predicate.expr)})"
elif isinstance(predicate, ast.StringSetMapIndex):
return f"{predicate.m.name}[{t_expr(predicate.key)}]"
elif isinstance(predicate, ast.StringSetMapIndexLen):
return f"len({t_expr(predicate.E)})"
elif isinstance(predicate, ast.StringSetMapIndexFirst):
return f"first({t_expr(predicate.E)})"
elif isinstance(predicate, ast.StringSetMapAddValue):
return f"map_add({t_expr(predicate.m.name)}, {t_expr(predicate.K)}, {t_expr(predicate.V)})"
elif isinstance(predicate, ast.StringSetMapRemoveKeys):
return f"map_remove({t_expr(predicate.m.name)}, {t_expr(predicate.K)})"
else:
return str(predicate)
raise Exception(f"unknown predicate type: {type(predicate)}")


class Policy:
Expand Down Expand Up @@ -261,17 +345,29 @@ def export(self):
"spec": {},
}

def group_rules(operator, rules):
scopes = {}
for rule in rules:
if rule.scope not in scopes:
scopes[rule.scope] = []

scopes[rule.scope].append(rule)

for scope, rules in scopes.items():
expr = functools.reduce(operator, rules)
scopes[scope] = t_expr(expr)

return scopes

if self.options.options:
options_rules = functools.reduce(operator.and_, self.options.options)
out["spec"]["options"] = transform_expr(options_rules)
out["spec"]["options"] = t_expr(options_rules)

if self.allow.rules:
allow_rules = functools.reduce(operator.or_, self.allow.rules)
out["spec"]["allow"] = transform_expr(allow_rules)
out["spec"]["allow"] = group_rules(operator.or_, self.allow.rules)

if self.deny.rules:
deny_rules = functools.reduce(operator.and_, self.deny.rules)
out["spec"]["deny"] = transform_expr(deny_rules)
out["spec"]["deny"] = group_rules(operator.and_, self.deny.rules)

return out

Expand Down

0 comments on commit 028a938

Please sign in to comment.