Skip to content

feat: ACL labels from environment variables#422

Merged
steveiliop56 merged 5 commits into
tinyauthapp:mainfrom
chrellrich:feat/label-from-env
Oct 21, 2025
Merged

feat: ACL labels from environment variables#422
steveiliop56 merged 5 commits into
tinyauthapp:mainfrom
chrellrich:feat/label-from-env

Conversation

@chrellrich
Copy link
Copy Markdown
Contributor

@chrellrich chrellrich commented Oct 19, 2025

This pull request adds support for reading ACL labels from environment variables.

As discussed in #356 the ability to use the features that labels allow ( limiting access, allowing some paths without authentication, etc. ) for apps that are not running in docker would be great.

This is a first implementation of reading labels from environment variables and would solve that issue.

The syntax is the same as Docker labels but uppercase and with dashes instead of dots.
For example: tinyauth.apps.[app].users.allow becomes TINYAUTH_APPS_[APP]_USERS_ALLOW

You can use both env-labels and Docker labels simultaneously. If both exist for an app, env-labels are prioritized.

Warning

Labels are not merged, so you can't set tinyauth.apps.[app].users.allow as a Docker label and TINYAUTH_APPS_[APP]_IP_ALLOW as an enviroment variable. In this case only the ip-allow config would be effective.

The feature is implemented as an additional service LabelService that is called by proxy_controller.

Example for Docker compose

The access to whoami is controlled with a label set as a environment variable on the tinyauth container.

In this example only john is allowed access to whoami but not sam.

Username Password
sam sam
john john
services:
  traefik:
    image: traefik:v3.3
    container_name: traefik
    command: --api.insecure=true --providers.docker
    restart: unless-stopped
    ports:
      - 80:80
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

  whoami:
    image: traefik/whoami:latest
    container_name: whoami
    restart: unless-stopped
    labels:
      traefik.enable: true
      traefik.http.routers.whoami.rule: Host(`whoami.example.com`)
      traefik.http.routers.whoami.middlewares: tinyauth

  tinyauth:
    image: ghcr.io/chrellrich/tinyauth:label-from-env
    container_name: tinyauth
    restart: unless-stopped
    environment:
      - APP_URL=https://tinyauth.example.com
      - USERS=sam:$$2a$$10$$1GtofEBYXeSwKhOxEKxkOuEE6rK4ii5g3K/Q4hw03lR2NBwfffHae,john:$$2a$$10$$YSYms0GckSJ8oiuh1Cgmx.Wu1pkPu3LKdn0e3LRM7dp2k3jYFPaqi
      - TINYAUTH_APPS_WHOAMI_CONFIG_DOMAIN=whoami.example.com
      - TINYAUTH_APPS_WHOAMI_USERS_ALLOW=john
    labels:
      traefik.enable: true
      traefik.http.routers.tinyauth.rule: Host(`tinyauth.example.com`)
      traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth:3000/api/auth/traefik

Summary by CodeRabbit

  • New Features
    • Access controls can now be configured via environment variables, providing an alternative to Docker labels for managing application access policies and permissions.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Oct 19, 2025

Walkthrough

A new AccessControlsService is introduced to abstract access control management, consolidating Docker-based label retrieval with environment-based ACL configuration. ProxyController is refactored to depend on this service instead of DockerService. AuthService method signatures are updated to work with the new config.App-based ACL model across multiple methods.

Changes

Cohort / File(s) Summary
New AccessControlsService
internal/service/access_controls_service.go
New service managing access controls with environment variable parsing (TINYAUTH_APPS_*), constructor NewAccessControlsService(docker *DockerService), Init() method for initialization, and GetAccessControls(appDomain string) method that prioritizes environment ACLs over Docker labels.
ProxyController Refactoring
internal/controller/proxy_controller.go
Replaces DockerService dependency with AccessControlsService; updates NewProxyController constructor signature; changes all label-based retrieval to ACL-based via controller.acls.GetAccessControls(host); updates error messages and decision logic (bypass, auth, IP, OAuth, headers) to operate on ACLs instead of labels; changes setHeaders signature parameter from labels to acls.
Bootstrap Configuration
internal/bootstrap/app_bootstrap.go
Creates new AccessControlsService instance with service.NewAccessControlsService(dockerService) and injects it into ProxyController during bootstrap initialization.
ProxyController Tests
internal/controller/proxy_controller_test.go
Updates ProxyController constructor calls to pass AccessControlsService instead of DockerService; initializes AccessControlsService in test setup.
AuthService Signature Updates
internal/service/auth_service.go
Updates three method signatures—IsResourceAllowed, CheckIP, and IsBypassedIP—to accept config.App and config.AppIP types respectively instead of label types; renames internal references from labels to acls.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant ProxyController
    participant AccessControlsService
    participant Environment
    participant DockerService

    Client->>ProxyController: HTTP Request (host)
    ProxyController->>AccessControlsService: GetAccessControls(host)
    
    rect rgb(200, 220, 255)
    Note over AccessControlsService: Priority: Environment → Docker
    AccessControlsService->>Environment: lookupEnvACLs(host)
    alt Environment ACL found
        Environment-->>AccessControlsService: config.App (from env)
        AccessControlsService-->>ProxyController: config.App
    else No Environment ACL
        AccessControlsService->>DockerService: GetLabels(host)
        DockerService-->>AccessControlsService: config.App (from labels)
        AccessControlsService-->>ProxyController: config.App
    end
    end
    
    ProxyController->>ProxyController: Apply ACL rules<br/>(IP checks, OAuth, auth)
    ProxyController-->>Client: Response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

The PR involves substantial refactoring of ProxyController with widespread logic changes (label→ACL conversions throughout), introduction of a new service with environment variable parsing, bootstrap wiring changes, and AuthService signature updates across multiple methods. While many changes follow consistent patterns, the heterogeneous nature (new service, major controller refactor, multiple signature updates) and density of the ProxyController changes drive higher review complexity.

Possibly related PRs

  • feat: header based acls #337: Directly related modifications to ProxyController and AuthService operating on the config.App-based ACL model, including setHeaders and IsResourceAllowed signature changes.
  • refactor: unify labels #329: Related changes to controller and auth service codepaths with public signature updates (ProxyController constructor, auth methods) working with the app/labels model refactoring.

Poem

🐰 With ACLs now abstract and clear,
Environment whispers, Docker draws near,
Access controls dance through the stack,
From labels to config, a fresh attack!
Bootstrap binds tight, ProxyController thrives—
Refactored and ready, the service survives! ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The PR title "feat: ACL labels from environment variables" directly aligns with the primary objective stated in the PR description, which is to add support for reading ACL labels from environment variables to enable configuration for apps not running in Docker. The title is concise, clear, and uses specific technical terms (ACL labels, environment variables) that accurately convey the main feature being introduced. While the PR includes significant refactoring (moving from DockerService to AccessControlsService), the core user-facing change and primary objective is the ability to configure access controls via environment variables, which the title captures effectively. A teammate scanning the commit history would understand that this PR introduces environment variable-based ACL configuration.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
internal/controller/proxy_controller.go (1)

6-6: Avoid reflect.DeepEqual for empty struct checks.

Using reflect.DeepEqual to check if labels are empty is not idiomatic Go and adds unnecessary reflection overhead. Consider one of these alternatives:

  1. Check specific fields that indicate a valid label configuration
  2. Add an IsEmpty() method to config.App
  3. Use a simple comparison for pointer types or specific sentinel values

Example refactor - Option 1: Check specific fields

-	// Determine which labels to use, prioritizing environment variables
-	var labels config.App
-	if !reflect.DeepEqual(envLabels, config.App{}) {
-		log.Debug().Msg("Using labels from environment variables")
-		labels = envLabels
-	} else if !reflect.DeepEqual(dockerLabels, config.App{}) {
-		log.Debug().Msg("Using labels from Docker")
-		labels = dockerLabels
-	} else {
-		log.Debug().Msg("No labels found for resource")
-		labels = config.App{}
-	}
+	// Determine which labels to use, prioritizing environment variables
+	var labels config.App
+	hasEnvLabels := envLabels.Config.Domain != "" || len(envLabels.Users.Allow) > 0
+	hasDockerLabels := dockerLabels.Config.Domain != "" || len(dockerLabels.Users.Allow) > 0
+	
+	if hasEnvLabels {
+		log.Debug().Msg("Using labels from environment variables")
+		labels = envLabels
+	} else if hasDockerLabels {
+		log.Debug().Msg("Using labels from Docker")
+		labels = dockerLabels
+	} else {
+		log.Debug().Msg("No labels found for resource")
+		labels = config.App{}
+	}

If you go with Option 2 (IsEmpty method), add this to internal/config/config.go:

func (a App) IsEmpty() bool {
	return a.Config.Domain == "" && len(a.Users.Allow) == 0 && len(a.Users.Deny) == 0
	// Add other relevant field checks as needed
}

Also applies to: 100-111

internal/service/label_service.go (2)

10-10: Consider using stdlib slices package if on Go 1.21+.

The golang.org/x/exp/slices package is experimental. If this project uses Go 1.21 or later, prefer the standard library slices package instead.

-	"golang.org/x/exp/slices"
+	"slices"

Alternatively, if avoiding the dependency entirely is preferred, you can replace the slices.ContainsFunc call with a simple loop (see comment on line 25).


25-25: Consider a simple loop instead of slices.ContainsFunc.

While functionally correct, using slices.ContainsFunc adds a dependency on the experimental package for a simple prefix check. A straightforward loop would be clearer and avoid the dependency.

-	if slices.ContainsFunc(envVars, func(s string) bool { return strings.HasPrefix(s, "TINYAUTH_APPS_") }) {
+	hasAppEnvVars := false
+	for _, e := range envVars {
+		if strings.HasPrefix(e, "TINYAUTH_APPS_") {
+			hasAppEnvVars = true
+			break
+		}
+	}
+	if hasAppEnvVars {
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6647c6c and e7075ab.

📒 Files selected for processing (3)
  • internal/bootstrap/app_bootstrap.go (2 hunks)
  • internal/controller/proxy_controller.go (3 hunks)
  • internal/service/label_service.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
internal/service/label_service.go (2)
internal/config/config.go (1)
  • Apps (125-127)
internal/utils/decoders/label_decoder.go (1)
  • DecodeLabels (9-19)
internal/bootstrap/app_bootstrap.go (1)
internal/service/label_service.go (1)
  • NewLabelService (18-20)
internal/controller/proxy_controller.go (3)
internal/service/label_service.go (1)
  • LabelService (13-16)
internal/service/docker_service.go (1)
  • DockerService (14-18)
internal/config/config.go (1)
  • App (129-136)
🔇 Additional comments (7)
internal/bootstrap/app_bootstrap.go (3)

139-139: LGTM!

The label service instantiation follows the established pattern for service creation in the bootstrap sequence.


144-149: LGTM!

The label service is correctly added to the services initialization list and will be properly initialized in the loop.


246-248: LGTM!

The label service is correctly wired into the ProxyController constructor as a dependency.

internal/controller/proxy_controller.go (2)

25-41: LGTM!

The label service dependency is properly added to the ProxyController struct and constructor following standard dependency injection patterns.


82-98: LGTM!

The dual label fetching logic correctly retrieves labels from both environment variables and Docker, with consistent error handling for both sources.

internal/service/label_service.go (2)

36-66: LGTM!

The LoadLabels method correctly parses environment variables, converts them to the expected label format (TINYAUTH_APPS_[APP]_KEY → tinyauth.apps.[app].key), and properly handles decoding errors.


75-85: LGTM!

The label matching logic correctly handles both exact domain matches and subdomain-prefix matches, which aligns with the typical reverse proxy routing patterns.

Comment thread internal/service/label_service.go Outdated
Comment thread internal/service/label_service.go Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
internal/service/label_service.go (1)

71-92: Previous suggestion not yet addressed: empty appDomain validation.

The previous review recommended adding validation for empty appDomain to prevent incorrect matching behavior. An empty appDomain would currently match against apps with empty domain or empty name fields, which is likely unintended.

Apply this diff to add the validation:

 func (label *LabelService) GetLabels(appDomain string) (config.App, error) {
+	if appDomain == "" {
+		log.Debug().Msg("Empty appDomain provided, returning empty labels")
+		return config.App{}, nil
+	}
+	
 	// If no labels found in env, return empty labels
 	if !label.labelsFoundInEnv {
 		log.Debug().Msg("No labels found in environment, returning empty labels")
 		return config.App{}, nil
 	}
🧹 Nitpick comments (1)
internal/service/label_service.go (1)

71-71: Consider removing the unused error return.

The GetLabels method signature includes an error return, but the implementation never returns a non-nil error. If errors are not expected in this method, consider simplifying the signature to func (label *LabelService) GetLabels(appDomain string) config.App to better reflect the actual behavior and simplify caller code.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e7075ab and c5ab1bb.

📒 Files selected for processing (1)
  • internal/service/label_service.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
internal/service/label_service.go (2)
internal/config/config.go (2)
  • Apps (125-127)
  • Config (17-44)
internal/utils/decoders/label_decoder.go (1)
  • DecodeLabels (9-19)
🔇 Additional comments (3)
internal/service/label_service.go (3)

1-19: LGTM! Clean structure and appropriate dependencies.

The package structure, imports, type definition, and constructor follow Go best practices. The unexported fields properly encapsulate the service state.


21-39: Previous critical issue resolved: error now properly propagated.

The error returned by LoadLabels is now correctly handled and propagated to the caller (line 34), addressing the critical issue flagged in the previous review. The filtering of environment variables before passing to LoadLabels is also cleaner than passing all environment variables.


41-69: LGTM! Robust environment variable parsing and error handling.

The implementation correctly:

  • Uses SplitN(e, "=", 2) to preserve "=" characters in values
  • Transforms keys from uppercase with underscores to lowercase with dots
  • Handles malformed entries by skipping them
  • Propagates decoding errors appropriately
  • Updates state only on success

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c5ab1bb and 3e87831.

📒 Files selected for processing (5)
  • internal/bootstrap/app_bootstrap.go (2 hunks)
  • internal/controller/proxy_controller.go (7 hunks)
  • internal/controller/proxy_controller_test.go (2 hunks)
  • internal/service/access_controls_service.go (1 hunks)
  • internal/service/auth_service.go (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
internal/service/auth_service.go (2)
internal/config/config.go (3)
  • UserContext (97-108)
  • App (129-136)
  • AppIP (152-156)
internal/utils/security_utils.go (1)
  • CheckFilter (77-102)
internal/controller/proxy_controller.go (4)
internal/service/access_controls_service.go (1)
  • AccessControlsService (12-15)
internal/config/config.go (1)
  • App (129-136)
internal/utils/label_utils.go (1)
  • ParseHeaders (8-24)
internal/utils/security_utils.go (2)
  • GetSecret (13-28)
  • GetBasicAuth (43-46)
internal/service/access_controls_service.go (3)
internal/service/docker_service.go (1)
  • DockerService (14-18)
internal/config/config.go (1)
  • Apps (125-127)
internal/utils/decoders/label_decoder.go (1)
  • DecodeLabels (9-19)
internal/controller/proxy_controller_test.go (1)
internal/service/access_controls_service.go (1)
  • NewAccessControlsService (17-21)
internal/bootstrap/app_bootstrap.go (1)
internal/service/access_controls_service.go (1)
  • NewAccessControlsService (17-21)
🔇 Additional comments (16)
internal/bootstrap/app_bootstrap.go (2)

139-149: LGTM!

The AccessControlsService is correctly instantiated with dockerService as a dependency and properly added to the initialization sequence. The initialization order ensures dockerService is ready before aclsService.Init() is called.


246-248: LGTM!

The ProxyController constructor call is correctly updated to pass aclsService instead of dockerService, aligning with the new constructor signature.

internal/controller/proxy_controller_test.go (2)

42-45: LGTM!

The test correctly creates and initializes the AccessControlsService, mirroring the production bootstrap sequence.


65-67: LGTM!

The ProxyController constructor call in the test is correctly updated to pass accessControlsService, aligning with the new constructor signature.

internal/service/auth_service.go (3)

290-305: LGTM!

The IsResourceAllowed method signature is correctly updated to accept acls config.App, and all internal references are consistently updated to use the new parameter name.


372-404: LGTM!

The CheckIP method signature is correctly updated to accept acls config.AppIP, and all internal references are consistently updated throughout the method body.


406-421: LGTM!

The IsBypassedIP method signature is correctly updated to accept acls config.AppIP, and the internal reference to acls.Bypass is consistent with the new parameter.

internal/controller/proxy_controller.go (5)

24-38: LGTM!

The ProxyController struct and constructor are correctly refactored to use AccessControlsService instead of DockerService. The field naming and initialization are consistent.


79-88: LGTM!

The ACL retrieval logic is correctly updated to use controller.acls.GetAccessControls(host), and the error messages appropriately reference "access controls" instead of "labels".


92-142: LGTM!

All ACL references in the proxy handler are correctly updated to use the acls variable obtained from GetAccessControls. The method calls properly pass the appropriate ACL fields (acls.IP, acls.Path).


164-238: LGTM!

The remaining ACL references are correctly updated, including calls to IsResourceAllowed and IsInOAuthGroup with appropriate ACL fields.


268-284: LGTM!

The setHeaders method signature is correctly updated to accept acls config.App, and all internal references are consistently updated to use the ACL configuration.

internal/service/access_controls_service.go (4)

12-21: LGTM!

The AccessControlsService struct and constructor are well-designed. The service properly holds a reference to DockerService for fallback and envACLs for environment-based configuration.


23-41: LGTM!

The Init method correctly filters environment variables for the TINYAUTH_APPS_ prefix and delegates parsing to loadEnvACLs. Error handling is appropriate.


74-90: LGTM!

The lookupEnvACLs method implements a sensible lookup strategy with two matching modes:

  1. Exact domain match against Config.Domain
  2. App name match using the first subdomain component

This dual strategy provides flexibility in configuration while maintaining predictable behavior.


92-103: LGTM!

The GetAccessControls method correctly implements the priority logic where environment-based ACLs take precedence over Docker labels, with appropriate fallback for backward compatibility. The debug logging is helpful for troubleshooting.

Comment thread internal/service/access_controls_service.go
@codecov
Copy link
Copy Markdown

codecov Bot commented Oct 21, 2025

Codecov Report

❌ Patch coverage is 12.35955% with 78 lines in your changes missing coverage. Please review.
✅ Project coverage is 23.54%. Comparing base (97639ae) to head (3e87831).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
internal/service/access_controls_service.go 0.00% 62 Missing ⚠️
internal/controller/proxy_controller.go 55.00% 6 Missing and 3 partials ⚠️
internal/bootstrap/app_bootstrap.go 0.00% 4 Missing ⚠️
internal/service/auth_service.go 0.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #422      +/-   ##
==========================================
- Coverage   24.22%   23.54%   -0.68%     
==========================================
  Files          35       36       +1     
  Lines        2778     2862      +84     
==========================================
+ Hits          673      674       +1     
- Misses       2070     2153      +83     
  Partials       35       35              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@steveiliop56
Copy link
Copy Markdown
Member

Thank you @chrellrich!

@steveiliop56 steveiliop56 merged commit c5bb389 into tinyauthapp:main Oct 21, 2025
4 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Oct 29, 2025
@coderabbitai coderabbitai Bot mentioned this pull request Dec 30, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants