Skip to content

Commit

Permalink
Added support for multiple roles (previously, only the first one woul…
Browse files Browse the repository at this point in the history
…d be picked) (#2)
  • Loading branch information
weisdd authored Jun 21, 2021
1 parent c0e10f9 commit 3c49d45
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 34 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# CHANGELOG

## 0.4.0

* Added support for multiple roles (previously, only the first one would be picked).

## 0.3.0

* Added support for POST requests;
Expand Down
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# LFGW
# Label Filter Gateway (LFGW)

lfgw is a trivial reverse proxy based on `httputil` and `VictoriaMetrics/metricsql` with a purpose of dynamically rewriting requests to Prometheus-like backends.
LFGW is a trivial reverse proxy based on `httputil` and `VictoriaMetrics/metricsql` with a purpose of dynamically rewriting requests to Prometheus-like backends.

More specifically, it manipulates label filters in metric expressions to reduce the scope of metrics exposed to an end user.

## Key functions
## Key features

* a user is not restricted to just one LFGW-role, you are free to specify a few;
* non-opinionated proxying of requests with valid jwt tokens - there's Prometheus to decide whether request is valid or not;
* ACL-based request rewrites with implicit deny;
* supports Victoria Metrics' PromQL extensions;
Expand Down Expand Up @@ -62,6 +63,7 @@ Pluses:

Minuses:

* a user cannot have multiple roles (`When you have multiple roles, the first one that is mentioned in prometheus-acls will be used.`);
* based on Prometheus library, so might not support some of Victoria Metrics' extensions;
* does not allow to filter out requests to sensitive endpoints (like `/admin/tsdb`);
* does not rewrite requests to `/federate` endpoint (at least, at the time of writing);
Expand Down Expand Up @@ -96,7 +98,7 @@ OIDC roles are expected to be present in `roles` within a jwt token.
| | `OIDC_REALM_URL` | | OIDC Realm URL, e.g. `https://auth.microk8s.localhost/auth/realms/cicd` |
| | `OIDC_CLIENT_ID` | | OIDC Client ID (1*) |

(1*): since it's grafana who obtains jwt-tokens in the first place, the specified client id must also be present in the forwarded token (the `audience` field). To put it simply, better to use the same client id for both grafana and lfgw.
(1*): since it's grafana who obtains jwt-tokens in the first place, the specified client id must also be present in the forwarded token (the `audience` field). To put it simply, better to use the same client id for both Grafana and LFGW.

### acl.yaml syntax

Expand All @@ -109,11 +111,12 @@ role: namespace, namespace2
For example:
```yaml
team0: .* # all metrics
team1: minio # only those with namespace="minio"
team2: min.* # only those matching namespace=~"min.*"
team3: minio, stolon # only those matching namespace=~"^(minio|stolon)$"
team4: min.*, stolon # only those matching namespace=~"^(min.*|stolon)$"
team0: .* # all metrics
team1: min.*, .*, stolon # all metrics, it's the same as .*
team2: minio # only those with namespace="minio"
team3: min.* # only those matching namespace=~"min.*"
team4: minio, stolon # only those matching namespace=~"^(minio|stolon)$"
team5: min.*, stolon # only those matching namespace=~"^(min.*|stolon)$"
```
To summarize, here are the key principles used for rewriting requests:
Expand All @@ -124,3 +127,12 @@ To summarize, here are the key principles used for rewriting requests:
* `min.*` - positive regex-match label filters (`namespace=~"X"`) are removed, then `namespace=~"mi.*"` is added;
* `minio, stolon` - positive regex-match label filters (`namespace=~"X"`) are removed, then `namespace=~"^(minio|stolon)$"` is added;
* `min.*, stolon` - positive regex-match label filters (`namespace=~"X"`) are removed, then `namespace=~"^(min.*|stolon)$"` is added.

Note: a user is free to have multiple roles matching the contents of `acl.yaml`. Basically, there are 3 cases:

* one role
=> a prepopulated LF is returned;
* multiple roles, one of which gives full access
=> a prepopulated LF, corresponding to the full access role, is returned;
* multiple "limited" roles
=> definitions of all those roles are merged together, and then LFGW generates a new LF. The process is the same as if this meta-definition was loaded through `acl.yaml`.
90 changes: 74 additions & 16 deletions cmd/lfgw/acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type ACLMap map[string]*ACL
type ACL struct {
Fullaccess bool
LabelFilter metricsql.LabelFilter
RawACL string
}

// toSlice converts namespace rules to string slices.
Expand All @@ -28,6 +29,7 @@ func (a *ACL) toSlice(str string) ([]string, error) {
// in case there are empty elements in between
s := strings.TrimSpace(s)

// TODO: optionally disable it when things are loaded from a file?
for _, ch := range s {
if unicode.IsSpace(ch) {
return nil, fmt.Errorf("line should not contain spaces within individual elements (%q)", str)
Expand All @@ -53,10 +55,7 @@ func (a *ACL) PrepareLF(ns string) (metricsql.LabelFilter, error) {
IsNegative: false,
}

if ns == ".*" {
lf.Value = ns
lf.IsRegexp = true
}
// TODO: deduplication of ns?

buffer, err := a.toSlice(ns)
if err != nil {
Expand All @@ -69,6 +68,18 @@ func (a *ACL) PrepareLF(ns string) (metricsql.LabelFilter, error) {
lf.IsRegexp = true
}
} else {
// TODO: work with dicts? What's faster? - Slice or Dict->Slice?

// If .* is in the slice, then we can omit any other value
for _, v := range buffer {
// TODO: move to HasFullaccessValue?
if v == ".*" {
lf.Value = v
lf.IsRegexp = true
return lf, nil
}
}

lf.Value = fmt.Sprintf("^(%s)$", strings.Join(buffer, "|"))
lf.IsRegexp = true
}
Expand Down Expand Up @@ -99,7 +110,7 @@ func (app *application) loadACL() (ACLMap, error) {

for role, ns := range aclYaml {
acl := &ACL{}
if ns == ".*" {
if app.HasFullaccessValue(ns) {
acl.Fullaccess = true
}

Expand All @@ -108,30 +119,77 @@ func (app *application) loadACL() (ACLMap, error) {
return aclMap, err
}
acl.LabelFilter = lf
acl.RawACL = ns
aclMap[role] = acl
app.infoLog.Printf("Loaded role definition for %s: %q (converted to %s)", role, ns, acl.LabelFilter.AppendString(nil))
}

return aclMap, nil
}

// getUserRole returns a first role match between user's claims and the ACLMap.
func (app *application) getUserRole(roles []string) (string, error) {
for _, role := range roles {
// getUserRoles returns a list of role matches between user's claims and the ACLMap.
func (app *application) getUserRoles(oidcRoles []string) ([]string, error) {
var aclRoles []string

for _, role := range oidcRoles {
_, exists := app.ACLMap[role]
if exists {
return role, nil
aclRoles = append(aclRoles, role)
}
}
return "", fmt.Errorf("no matching role found")

if len(aclRoles) > 0 {
return aclRoles, nil
}

return []string{}, fmt.Errorf("no matching roles found")
}

// HasFullaccessValue returns true if a label filter gives access to all namespaces.
func (app *application) HasFullaccessValue(value string) bool {
return value == ".*"
}

// hasFullaccessRole says whether a role has offers a full access as per an acl spec.
func (app *application) hasFullaccessRole(role string) bool {
return app.ACLMap[role].Fullaccess
// rolesToRawACL returns a comma-separated list of ACL definitions for all specified roles.
// Basically, it lets you dynamically generate a raw ACL as if it was supplied via acl.yaml
func (app *application) rolesToRawACL(roles []string) string {
// TODO: rewrite with make?
// rawACLs := make([]string, 0, len(roles))
var rawACLs []string

for _, role := range roles {
rawACLs = append(rawACLs, app.ACLMap[role].RawACL)
}

return strings.Join(rawACLs, ", ")
}

// getLF returns a label filter associated with a specified role.
func (app *application) getLF(role string) metricsql.LabelFilter {
return app.ACLMap[role].LabelFilter
// getLF returns a label filter associated with a specified list of roles.
func (app *application) getLF(roles []string) (metricsql.LabelFilter, error) {
if len(roles) == 0 {
return metricsql.LabelFilter{}, fmt.Errorf("failed to construct a label filter (no roles supplied)")
}

if len(roles) == 1 {
role := roles[0]
return app.ACLMap[role].LabelFilter, nil
}

// If a user has a fullaccess role, there's no need to check any other one
for _, role := range roles {
if app.ACLMap[role].Fullaccess {
return app.ACLMap[role].LabelFilter, nil
}
}

ns := app.rolesToRawACL(roles)

acl := &ACL{}

lf, err := acl.PrepareLF(ns)
if err != nil {
return metricsql.LabelFilter{}, err
}

return lf, nil
}
4 changes: 2 additions & 2 deletions cmd/lfgw/acl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
)

func TestACL_ToSlice(t *testing.T) {
acl := &ACL{false, metricsql.LabelFilter{}}
acl := &ACL{false, metricsql.LabelFilter{}, ""}

tests := []struct {
name string
Expand Down Expand Up @@ -62,7 +62,7 @@ func TestACL_ToSlice(t *testing.T) {
}

func TestACL_PrepareLF(t *testing.T) {
acl := &ACL{false, metricsql.LabelFilter{}}
acl := &ACL{false, metricsql.LabelFilter{}, ""}

tests := []struct {
name string
Expand Down
2 changes: 1 addition & 1 deletion cmd/lfgw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ type application struct {

type contextKey string

const contextKeyHasFullaccessRole = contextKey("hasFullaccessRole")
const contextKeyHasFullaccess = contextKey("hasFullaccess")
const contextKeyLabelFilter = contextKey("labelFilter")

func main() {
Expand Down
18 changes: 12 additions & 6 deletions cmd/lfgw/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,23 @@ func (app *application) oidcModeMiddleware(next http.Handler) http.Handler {
return
}

userRole, err := app.getUserRole(claims.Roles)
userRoles, err := app.getUserRoles(claims.Roles)
if err != nil {
app.errorLog.Printf("%s (%s, %s)", err, claims.Username, claims.Email)
app.clientErrorMessage(w, http.StatusUnauthorized, err)
return
}

hasFullaccessRole := app.hasFullaccessRole(userRole)
lf := app.getLF(userRole)
lf, err := app.getLF(userRoles)
if err != nil {
app.errorLog.Printf("%s (%s, %s)", err, claims.Username, claims.Email)
app.clientErrorMessage(w, http.StatusUnauthorized, err)
return
}

hasFullaccess := app.HasFullaccessValue(lf.Value)

ctx = context.WithValue(ctx, contextKeyHasFullaccessRole, hasFullaccessRole)
ctx = context.WithValue(ctx, contextKeyHasFullaccess, hasFullaccess)
ctx = context.WithValue(ctx, contextKeyLabelFilter, lf)
r = r.WithContext(ctx)

Expand All @@ -121,8 +127,8 @@ func (app *application) rewriteRequestMiddleware(next http.Handler) http.Handler
}

// Since the value is false by default, we don't really care if it exists in the context
hasFullaccessRole, _ := r.Context().Value(contextKeyHasFullaccessRole).(bool)
if hasFullaccessRole {
hasFullaccess, _ := r.Context().Value(contextKeyHasFullaccess).(bool)
if hasFullaccess {
app.debugLog.Print("Request is passed through")
next.ServeHTTP(w, r)
return
Expand Down

0 comments on commit 3c49d45

Please sign in to comment.