Skip to content

Add SFTPGo Authentication and Provisioning Service#71

Open
rksk wants to merge 8 commits intowso2-open-operations:mainfrom
rksk:sftp-server-webooks
Open

Add SFTPGo Authentication and Provisioning Service#71
rksk wants to merge 8 commits intowso2-open-operations:mainfrom
rksk:sftp-server-webooks

Conversation

@rksk
Copy link

@rksk rksk commented Jan 28, 2026

Purpose

This PR introduces the SFTPGo Authentication Service, a Go-based middleware designed to handle SFTPGo's pre-login and keyboard-interactive authentication hooks. It acts as a secure bridge between SFTPGo and Identity Providers (Asgardeo) to handle user identification, route requests to the correct organization, and handle dynamic directory provisioning.

Goals

  • Provide seamless integration between SFTPGo and Asgardeo.
  • Support Dual-Organization routing based on user domains (Internal staff vs. External customers).
  • Enable Dynamic User Provisioning, mapping IdP roles to SFTPGo permissions and virtual folders.
  • Implement an interactive MFA flow (TOTP/OTP) with session persistence.

Approach

  • Clean Architecture: Built with decoupled services for IdP (Asgardeo), SFTPGo Admin API, and Subscription management.
  • Resilient Communication: Implemented a custom HTTP transport with technical safety limits (10KB logging caps) to prevent memory exhaustion during TRACE logging.
  • Session Management: Leveraged a MySQL-backed session store for managing multi-step authentication states.
  • Choreo & OpenAPI: Integrated OpenAPI 3.0 specifications and Choreo-specific configuration for streamlined deployment.

User stories

  • Internal Users: As a staff member, I can log in using my corporate credentials and automatically access shared project folders assigned to my role.
  • External Users: As a customer, I can log in and have my specific subscription folders dynamically provisioned upon successful authentication.
  • Administrators: As an admin, I can audit all login attempts and debug technical issues via secure, rate-limited trace logs.

Release note

Initial release of the SFTPGo Authentication Service supporting Asgardeo integration, dynamic role-based provisioning, and multi-step keyboard-interactive authentication.

Documentation

Includes a comprehensive README.md detailing the architecture, setup, and environment configurations, along with a full openapi.yaml specification.

Training

N/A

Certification

N/A - This change does not impact existing certification exams.

Marketing

N/A

Automation tests

  • Unit tests
    • 100% pass rate for internal/config, internal/handler, internal/service, and internal/util.
  • Integration tests
    • Includes mock HTTP server tests for external API integrations (Subscription and IdP endpoints).

Security checks

Samples

The openapi.yaml file provides high-level details and request/response samples for the hook endpoints.

Related PRs

N/A

Migrations (if applicable)

Includes db/migrations/001_create_sessions_table.up.sql to initialize the required MySQL schema for session persistence.

Test environment

  • OS: MacOS, Linux (Docker)
  • Database: MySQL 8.0
  • Language: Go 1.21+

Learning

Researched SFTPGo external hook protocols and Asgardeo's app-native authentication APIs to implement the multi-step keyboard-interactive flow.

Summary by CodeRabbit

  • New Features
    • Adds SFTPGo Authentication Service with pre-login provisioning, multi-step keyboard-interactive auth, folder provisioning, IdP integration, and API-key protection.
  • Documentation
    • Includes comprehensive README, OpenAPI spec and environment example for setup and API usage.
  • Tests
    • Adds unit tests covering config, auth flows, utils and service integrations.
  • Chores
    • Adds containerization, logging utilities and license.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 28, 2026

📝 Walkthrough

Walkthrough

Adds a new SFTPGo Authentication Service: REST endpoints (/prelogin-hook, /auth-hook), IdP and SFTPGo Admin integrations, MySQL-backed session persistence, config/logging/http client utilities, OpenAPI spec, Dockerfile, README/LICENSE, and extensive unit tests and migrations.

Changes

Cohort / File(s) Summary
Metadata & Env
operations/sftpgo-authentication-service/.choreo/component.yaml, operations/sftpgo-authentication-service/.env.example, operations/sftpgo-authentication-service/.gitignore, operations/sftpgo-authentication-service/go.mod
Adds component manifest, environment template, .gitignore, and module definition with dependencies.
Docs, License & Docker
operations/sftpgo-authentication-service/README.md, operations/sftpgo-authentication-service/LICENSE, operations/sftpgo-authentication-service/Dockerfile
Adds README, Apache-2.0 license, and a multi-stage Dockerfile producing a non-root runtime image.
API Spec
operations/sftpgo-authentication-service/openapi.yaml
New OpenAPI 3.0 spec defining /prelogin-hook and /auth-hook, request/response schemas and ApiKeyAuth security.
Server Entrypoint
operations/sftpgo-authentication-service/cmd/server/main.go
New main that loads config, initializes logger/services, registers routes, and runs HTTP server with graceful shutdown.
Configuration
operations/sftpgo-authentication-service/internal/config/config.go, .../config_test.go
Env-driven Config loader with defaults, validation, computed endpoints, and tests for success/missing/defaults.
Models
operations/sftpgo-authentication-service/internal/models/models.go
Domain models for SFTPGo payloads, IdP responses, sessions, SCIM user structures, and auth prompts.
Handlers & Auth flow
operations/sftpgo-authentication-service/internal/handler/...
New Handler, PreLoginHook and AuthHandler, API-key gating, multi-step IdP orchestration, session persistence, prompt generation, utilities, and tests (handler.go, utils.go, *_test.go).
Database schema & service
operations/sftpgo-authentication-service/db/migrations/001_create_sessions_table.up.sql, operations/sftpgo-authentication-service/internal/service/database.go
Adds sessions table migration and DBService with Save/Get/Delete semantics, TTL handling, pooling, and no-op behavior when DB unconfigured.
IdP service
operations/sftpgo-authentication-service/internal/service/idp.go, .../idp_test.go
IdPService for SCIM lookup, InitFlow, PostToAuthnEndpoint, token handling, and internal-user detection tests.
SFTPGo admin service
operations/sftpgo-authentication-service/internal/service/sftpgo.go, .../sftpgo_test.go
SFTPGoService to validate/provision folders and update user config; folder-name validation tests.
Subscription service
operations/sftpgo-authentication-service/internal/service/subscription.go, .../subscription_test.go
SubscriptionService to fetch user project keys and validate project keys; tests with mocked HTTP servers.
HTTP client & logging
operations/sftpgo-authentication-service/internal/httpclient/client.go, operations/sftpgo-authentication-service/internal/log/logger.go
Logging-aware HTTP RoundTripper/client and AppLogger with leveled logging and helpers.
Utilities & tests
operations/sftpgo-authentication-service/internal/util/...
Email regex initialization, username sanitization, internal-user detection, folder validation, and comprehensive unit tests.
Unit tests (misc)
operations/sftpgo-authentication-service/internal/*_test.go
Multiple new unit tests covering config, handler auth gating, IdP logic, util functions, SFTPGo folder validation, subscription flows, and DB session behavior.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant SFTPGo as SFTPGo
    participant Handler as PreLoginHook_Handler
    participant IdP as IdP_Service
    participant Subscription as Subscription_API
    participant SFTPAdmin as SFTPGo_Admin_API
    participant DB as Database

    Client->>SFTPGo: User login attempt
    SFTPGo->>Handler: POST /prelogin-hook (username)
    Handler->>Handler: Validate API key
    Handler->>IdP: SCIM lookup (GetAsgardeoUser)
    IdP-->>Handler: User data
    Handler->>Subscription: GetUserFolderList(username)
    Subscription-->>Handler: Project keys / folders
    Handler->>SFTPAdmin: ProvisionFolders / UpdateUser
    SFTPAdmin-->>Handler: Provisioning result
    Handler->>DB: Optional audit/session save
    Handler-->>SFTPGo: MinimalSFTPGoUser (200)
Loading
sequenceDiagram
    actor Client
    participant SFTPGo as SFTPGo
    participant Handler as AuthHandler
    participant DB as Database
    participant IdP as IdP_Service
    participant SFTPAdmin as SFTPGo_Admin_API

    Client->>SFTPGo: Start keyboard-interactive auth (step 1)
    SFTPGo->>Handler: POST /auth-hook (requestId, step, username)
    Handler->>Handler: Validate API key
    alt Step 1
        Handler->>IdP: InitFlow(username)
        IdP-->>Handler: FlowId + NextStep
        Handler->>DB: SaveSession(requestId, sessionData)
        Handler-->>SFTPGo: AuthHookResponse (questions)
    else Subsequent step
        Handler->>DB: GetSession(requestId)
        DB-->>Handler: sessionData
        Handler->>IdP: PostToAuthnEndpoint(payload)
        IdP-->>Handler: IdPResponse (incomplete/success/fail)
        alt Incomplete
            Handler->>DB: UpdateSession
            Handler-->>SFTPGo: Next questions
        else Success
            Handler->>SFTPAdmin: UpdateUser (permissions/folders)
            Handler->>DB: DeleteSession
            Handler-->>SFTPGo: AuthHookResponse (success)
        else Failure
            Handler->>DB: DeleteSession
            Handler-->>SFTPGo: AuthHookResponse (failure)
        end
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 I hopped through configs, tokens, and a spec so bright,
Prompts and folders stitched in tidy light,
Sessions snug in MySQL's burrow deep,
Admin calls danced while logs hummed to keep,
A rabbit's cheer — auth springs awake tonight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.63% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a new SFTPGo Authentication and Provisioning Service, which is the central focus of all the changeset additions.
Description check ✅ Passed The pull request description is comprehensive and follows the repository template structure, covering all major sections including Purpose, Goals, Approach, User stories, Release note, Documentation, Security checks, and Test environment details.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

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

@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: 18

🤖 Fix all issues with AI agents
In `@operations/sftpgo-authentication-service/.gitignore`:
- Around line 2-3: Remove the accidental stray ignore pattern "*.exe~" from the
.gitignore in the sftpgo-authentication-service; either delete that specific
line or replace it with a generic backup pattern like "*~" if you intend to
ignore editor backup files, ensuring consistency with other entries (do not add
file content examples here).

In `@operations/sftpgo-authentication-service/cmd/server/main.go`:
- Around line 46-49: The InitEmailRegex error is currently logged but startup
continues; update the main initialization to fail fast when
util.InitEmailRegex(cfg.EmailRegexPattern) returns an error by logging the error
and terminating startup (e.g., call logger.Fatalf or logger.Error(...) followed
by os.Exit(1) or return from main). Modify the block around util.InitEmailRegex
to ensure the process exits with a non-zero status on error so validation won't
run with an uninitialized regex.
- Around line 71-74: The http.Server instance named server is missing
ReadTimeout, WriteTimeout, and IdleTimeout to mitigate slowloris-style attacks;
update the server literal where http.Server is constructed (the server variable)
to include ReadTimeout, WriteTimeout, and IdleTimeout with sensible defaults
(e.g., ReadTimeout ~5s, WriteTimeout ~10s, IdleTimeout ~120s) and add the time
package import so these values use time.Duration (time.Second). Keep the
existing Addr and Handler settings unchanged.

In `@operations/sftpgo-authentication-service/go.mod`:
- Line 18: Update the module Go version directive in go.mod from "go 1.24" to
"go 1.25.6" (edit the go directive in the go.mod file), then run go mod tidy and
rebuild/tests to ensure dependencies and toolchain compatibility; also update
any CI/workflow specs or Dockerfiles that pin Go to 1.24 to use 1.25.6 so the
project and CI use the same Go version.
- Around line 20-22: The go.mod currently pins github.com/go-sql-driver/mysql to
v1.8.1 which is outdated; update the MySQL driver to v1.9.3 by running `go get
github.com/go-sql-driver/mysql@v1.9.3`, then run `go mod tidy` to refresh
go.mod/go.sum (leave filippo.io/edwards25519 at v1.1.0 as-is), and run the test
suite/integration tests to verify there are no regressions in the authentication
code that uses the mysql driver.

In `@operations/sftpgo-authentication-service/internal/config/config_test.go`:
- Around line 65-74: TestLoad_MissingCritical currently calls os.Clearenv() and
doesn't restore the process environment, which can break other tests; modify the
test to snapshot the current environment (e.g., capture os.Environ()), call
os.Clearenv(), then defer a restore that repopulates the environment (or clears
and repopulates from the snapshot) after the test completes; ensure you
reference the TestLoad_MissingCritical test and the Load() call when making
changes and add the "strings" import as noted.

In `@operations/sftpgo-authentication-service/internal/handler/handler.go`:
- Around line 224-275: The audit log in AuthHandler currently logs the raw
project key when isProjectKeyStep is true; change this to redact or hash the
project key before including it in the detail passed to auditLog: locate the
branch that builds detail using req.Answers[0] (in AuthHandler) and replace the
direct value with a sanitized version (e.g., a deterministic hash or masked
value like showing only last N chars) so the raw key is never logged; ensure the
redaction/hashing logic is applied only to the value used in detail and that no
other logs or variables (resp, req) are left logging the raw key.
- Around line 68-220: The PreLoginHook handler currently returns an anonymous
user on IdP lookup failure; remove the anonymous fallback and instead return
http.StatusNotFound to fail closed. In PreLoginHook, delete the
anonymousUsername / home / perms / vfs construction and the writeJSONResponse
call that returns the anonymous user, and replace that block with a single
w.WriteHeader(http.StatusNotFound) (or use http.Error) after calling
h.auditLog(r, u.Username, "pre-login-hook", "denied", "user not found in IdP").
The change affects the error branch that checks err after calling
h.idp.GetAsgardeoUser; ensure the audit log message remains and the function
returns immediately after sending 404.

In `@operations/sftpgo-authentication-service/internal/handler/utils_test.go`:
- Around line 45-57: The test TestSanitizeUsername in utils_test.go uses the
wrong expected value and contains no assertion; update the expected string to
"user_email_com" to match util.SanitizeUsername (which replaces both '@' and '.'
with '_'), and replace the no-op conditional with a proper t.Fatalf or t.Errorf
assertion that fails when sanitizeUsername(input) != expected; reference the
sanitizeUsername wrapper and util.SanitizeUsername behavior when making the
change.

In `@operations/sftpgo-authentication-service/internal/handler/utils.go`:
- Around line 195-205: The handler handleProjectKeyStep currently uses
unvalidated projectKey to build virtualPath and mappedPath which allows path
traversal; validate and sanitize projectKey before use by enforcing an allowlist
(e.g., only lowercase letters, numbers, hyphen/underscore) and rejecting inputs
containing path separators or dots, and after building mappedPath with
filepath.Join(h.cfg.FolderPath, projectKey) verify the final path does not
escape the base folder (use filepath.Clean + filepath.Rel to ensure the relative
path does not start with ".."); fail the request (set resp.AuthResult =
AuthResultFailure and a clear Instruction) if validation or the final
base-folder check fails so UserVirtualFolder entries are never created from
malicious input.
- Around line 78-89: The handler handleAuthStep1 currently uses req.Username
directly when initiating the IdP flow and logging; run the existing
validateUsername(req.Username) first and if it returns false set resp.AuthResult
= AuthResultFailure and resp.Instruction to a clear failure message, then return
without calling h.idp.InitFlow; also avoid logging the raw username in
h.logger.Error — either log a sanitized/masked username or omit it entirely when
reporting the InitFlow error. This ensures CR/LF and oversize usernames are
rejected before reaching idp.InitFlow or being written to logs.

In `@operations/sftpgo-authentication-service/internal/httpclient/client.go`:
- Around line 55-80: The logRequest and logResponse functions currently read up
to 10KB from req.Body/resp.Body and then replace the body with only those bytes,
truncating larger payloads; instead, read the entire body into a buffer (or
stream through a TeeReader) to restore the full body for transmission, but when
logging only use the first 10KB for Trace output. Concretely, in
LoggingTransport.logRequest and LoggingTransport.logResponse capture the full
body (e.g., io.ReadAll or io.Copy into a bytes.Buffer), then set
req.Body/resp.Body back to a new ReadCloser that wraps the full buffer, and pass
a sliced view (first 10KB) to t.Logger.Trace so logs are limited while the full
body is preserved for the actual request/response.

In `@operations/sftpgo-authentication-service/internal/log/logger.go`:
- Around line 107-111: AppLogger.Errorf currently pre-formats the message then
calls AppLogger.Error which treats its input as a format string, causing
double-formatting; fix Errorf to send the already-formatted message to the
underlying logger using a safe "%s" format (or a non-formatting Print/Println)
instead of passing the raw formatted string as a format specifier, e.g. call the
logger with "%s", msg (or logger.Print/Println) and return errors.New(msg);
update the AppLogger.Errorf implementation and ensure you reference
AppLogger.Errorf and the underlying logger used by AppLogger.Error when making
the change.

In `@operations/sftpgo-authentication-service/internal/models/models.go`:
- Around line 81-96: The JSON tags on the KeyIntRequest struct are camelCase but
must match the OpenAPI snake_case names; update the struct tags on KeyIntRequest
(fields RequestID, Step, Username, Answers) to use "request_id", "step",
"username", and "answers" respectively so JSON unmarshalling aligns with the
OpenAPI schema and incoming SFTPGo payloads.

In `@operations/sftpgo-authentication-service/internal/service/idp.go`:
- Around line 140-182: The InitFlow method on IdPService is including the client
secret in the authorize request form (form.Set("client_secret",
ctx.clientSecret)), which must be removed; update InitFlow to stop adding
ctx.clientSecret to the form (leave client_secret out of the authorize POST) so
only client_id, response_type, redirect_uri, scope and response_mode are sent to
ctx.authorizeEP, and keep all token-exchange uses of the secret in
getBearerToken (or other server-to-server token endpoint code) unchanged.

In `@operations/sftpgo-authentication-service/internal/service/sftpgo.go`:
- Around line 101-129: ProvisionFolders currently logs validation/check/create
failures but always returns nil; modify ProvisionFolders (method on
SFTPGoService) to propagate errors to callers: when validateFolderName,
checkFolderExists, or createFolder return an error, either return that error
immediately (fail-fast) or append it to an aggregated error slice and return a
combined error at the end; ensure getAdminToken errors are already propagated
and that any returned error includes context (folder name and operation) so
callers can react appropriately.

In `@operations/sftpgo-authentication-service/openapi.yaml`:
- Around line 237-243: The OpenAPI spec currently defines the ApiKeyAuth
security scheme under components.securitySchemes but lacks a top-level servers
declaration to enforce HTTPS; add a top-level servers block (e.g., servers: -
url: "https://api.example.com" description: "HTTPS API endpoint") at the root of
openapi.yaml so all endpoints using ApiKeyAuth require TLS, ensuring API keys
are not sent over cleartext (update the root document near components and
reference ApiKeyAuth implicitly via this servers entry).

In `@operations/sftpgo-authentication-service/README.md`:
- Line 68: The README's Go version "Go 1.23+" is inconsistent with the
Dockerfile base image `golang:1.24-alpine`; update the README to match the
Dockerfile (change "Go 1.23+" to "Go 1.24+") or, if you prefer to stay on 1.23,
update the Dockerfile image tag accordingly—ensure the version string in
README.md and the Dockerfile image tag (`golang:1.24-alpine`) are identical so
documentation and build config align.
🧹 Nitpick comments (13)
operations/sftpgo-authentication-service/go.mod (1)

20-22: Surprisingly minimal dependency list for the described service scope.

The PR describes a comprehensive service with HTTP handlers, advanced logging, OpenAPI specs, IdP integration, and resilient HTTP clients. However, only the MySQL driver is declared as a direct dependency. While Go's standard library is powerful, confirm that:

  1. No HTTP routing framework (e.g., chi, gorilla/mux, gin) is needed
  2. Standard library logging meets "advanced logging and audit" requirements
  3. No structured logging library (e.g., zap, zerolog) is required
  4. OpenAPI spec generation/validation doesn't require additional tooling
  5. HTTP client resilience patterns are implemented without helper libraries

If the standard library approach is intentional, this is commendable for minimizing dependencies. However, if additional libraries would improve maintainability or reduce custom boilerplate (especially for structured logging and HTTP client resilience), consider evaluating them.

operations/sftpgo-authentication-service/Dockerfile (2)

33-33: Pin Alpine version and install CA certificates.

Using alpine:latest can cause non-reproducible builds. Since this service makes HTTPS calls to Asgardeo and external APIs, ca-certificates is required in the final image.

🔧 Proposed fix
-FROM alpine:latest
+FROM alpine:3.19
 
 # Set the working directory to /app
 WORKDIR /app
+
+# Install CA certificates for HTTPS connections
+RUN apk --no-cache add ca-certificates

23-27: Consider optimizing Docker layer caching.

Copying go.mod and go.sum first and running go mod download before copying the full source improves build cache efficiency when only source files change.

♻️ Suggested optimization
-# Copy the Go source file into the container
-COPY . .
+# Copy go.mod and go.sum first for better caching
+COPY go.mod go.sum ./
+RUN go mod download
 
-# Run unit tests
-RUN go test ./...
+# Copy the rest of the source
+COPY . .
 
+# Run unit tests
+RUN go test ./...
operations/sftpgo-authentication-service/.env.example (2)

28-28: Inline comments may not parse correctly in all .env loaders.

Some .env parsers don't support inline comments. Consider moving the comment to a separate line above.

♻️ Suggested fix
-CHECK_ROLE="internal" # The role display name to check for internal users
+# The role display name to check for internal users
+CHECK_ROLE="internal"

36-36: Add trailing newline at end of file.

Missing newline at EOF per POSIX conventions and linter warning.

♻️ Suggested fix
 DB_CONN_STRING="user:password@tcp(127.0.0.1:3306)/sftpgo_sessions"
+
operations/sftpgo-authentication-service/internal/util/util.go (2)

52-58: Simplify IsLikelyEmail and consider nil guard.

The function can be simplified. Also, if emailRegex is somehow nil (e.g., custom pattern fails during startup before proper error handling), this would panic.

♻️ Suggested simplification
 // IsLikelyEmail checks if a string broadly resembles an email address.
 func IsLikelyEmail(s string) bool {
-	if !emailRegex.MatchString(s) {
-		return false
-	}
-	return true
+	return emailRegex != nil && emailRegex.MatchString(s)
 }

42-45: Consider logging or returning error from init.

The init() function silently discards the error from InitEmailRegex. If the default pattern ever becomes invalid, this would be a silent failure causing IsLikelyEmail to potentially panic or behave unexpectedly.

♻️ Suggested improvement
 func init() {
 	// Initialize with default pattern
-	_ = InitEmailRegex("")
+	if err := InitEmailRegex(""); err != nil {
+		panic("failed to compile default email regex: " + err.Error())
+	}
 }
operations/sftpgo-authentication-service/internal/util/init_test.go (2)

41-49: Hardcoded pattern creates maintenance burden.

TestEmailRegexCompilation duplicates the pattern string from util.go. If the default pattern changes, this test won't verify the actual pattern used by the package.

Consider either:

  1. Exporting the default pattern for testing, or
  2. Removing this test since TestInitEmailRegex already verifies compilation works.

24-39: Consider adding negative test cases.

The test only validates that a valid email passes. Adding cases for invalid emails (e.g., missing @, invalid TLD, consecutive dots) would strengthen the test coverage.

♻️ Suggested addition
// Add after line 38
// Test invalid emails should not match
invalidEmails := []string{
	"plaintext",
	"@example.com",
	"user@",
	"user@.com",
	"user@@example.com",
}
for _, email := range invalidEmails {
	if IsLikelyEmail(email) {
		t.Errorf("Default regex should NOT match invalid email: %s", email)
	}
}
operations/sftpgo-authentication-service/openapi.yaml (1)

336-341: Add payload bounds to user-supplied arrays.
Consider maxItems (and item length limits) for answers (and similarly for questions/echos) to reduce oversized payload risk when using OpenAPI-based validation/gateways.

♻️ Example (pick appropriate limits)
         answers:
           type: array
           description: An array of strings containing the user's responses to questions from the previous step.
           items:
             type: string
+            maxLength: 1024
+          maxItems: 4
operations/sftpgo-authentication-service/internal/config/config.go (1)

162-177: Normalize base paths before concatenating endpoints.
If base paths include a trailing /, the current concatenation yields // in computed endpoints. Trimming once improves robustness.

♻️ Suggested fix
-	// Compute endpoints
-	cfg.AdminTokenEP = cfg.SFTPGoBasePath + "/token"
-	cfg.SftpgoFoldersEP = cfg.SFTPGoBasePath + "/folders"
-	cfg.SftpgoUsersEP = cfg.SFTPGoBasePath + "/users"
-	cfg.IdPTokenEP = cfg.InternalIdPBasePath + "/oauth2/token"
-	cfg.IdPSCIMUsersEP = cfg.InternalIdPBasePath + "/scim2/Users"
-	cfg.IdPAuthnEP = cfg.InternalIdPBasePath + "/oauth2/authn"
-	cfg.IdPAuthorizeEP = cfg.InternalIdPBasePath + "/oauth2/authorize/"
+	// Compute endpoints
+	sftpgoBase := strings.TrimRight(cfg.SFTPGoBasePath, "/")
+	idpBase := strings.TrimRight(cfg.InternalIdPBasePath, "/")
+	cfg.AdminTokenEP = sftpgoBase + "/token"
+	cfg.SftpgoFoldersEP = sftpgoBase + "/folders"
+	cfg.SftpgoUsersEP = sftpgoBase + "/users"
+	cfg.IdPTokenEP = idpBase + "/oauth2/token"
+	cfg.IdPSCIMUsersEP = idpBase + "/scim2/Users"
+	cfg.IdPAuthnEP = idpBase + "/oauth2/authn"
+	cfg.IdPAuthorizeEP = idpBase + "/oauth2/authorize/"
@@
-	if cfg.ExternalIdPBasePath != "" {
-		cfg.ExternalIdPTokenEP = cfg.ExternalIdPBasePath + "/oauth2/token"
-		cfg.ExternalIdPSCIMUsersEP = cfg.ExternalIdPBasePath + "/scim2/Users"
-		cfg.ExternalIdPAuthnEP = cfg.ExternalIdPBasePath + "/oauth2/authn"
-		cfg.ExternalIdPAuthorizeEP = cfg.ExternalIdPBasePath + "/oauth2/authorize/"
-	}
+	if cfg.ExternalIdPBasePath != "" {
+		externalBase := strings.TrimRight(cfg.ExternalIdPBasePath, "/")
+		cfg.ExternalIdPTokenEP = externalBase + "/oauth2/token"
+		cfg.ExternalIdPSCIMUsersEP = externalBase + "/scim2/Users"
+		cfg.ExternalIdPAuthnEP = externalBase + "/oauth2/authn"
+		cfg.ExternalIdPAuthorizeEP = externalBase + "/oauth2/authorize/"
+	}
operations/sftpgo-authentication-service/internal/service/sftpgo.go (1)

52-85: Validate and URL-escape identifiers before building path segments.
username and projectKey are interpolated into URLs directly; escaping and reusing validateFolderName will harden against invalid characters and path-segment issues.

🔒 Suggested hardening
 import (
 	"bytes"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
 	"path/filepath"
 	"strings"
 	"time"
@@
 func (s *SFTPGoService) UpdateUser(username, projectKey string, perms map[string][]string, vfs []models.UserVirtualFolder) error {
 	s.logger.Debug("Updating user %s to provide access to %s", username, projectKey)
+	if err := validateFolderName(projectKey); err != nil {
+		return s.logger.Errorf("invalid project key '%s': %v", projectKey, err)
+	}
@@
-	sftpgoUserEP := s.cfg.SftpgoUsersEP + "/" + username
+	sftpgoUserEP := s.cfg.SftpgoUsersEP + "/" + url.PathEscape(username)
@@
 func (s *SFTPGoService) checkFolderExists(name, token string) (bool, error) {
-	endpoint := s.cfg.SftpgoFoldersEP + "/" + name
+	endpoint := s.cfg.SftpgoFoldersEP + "/" + url.PathEscape(name)

Also applies to: 156-167

operations/sftpgo-authentication-service/internal/service/subscription.go (1)

41-48: Use configured HTTP timeout instead of a fixed 10s.
Keeps behavior consistent with other services and honors config.

♻️ Suggested change
func NewSubscriptionService(cfg *config.Config, logger *log.AppLogger) *SubscriptionService {
	return &SubscriptionService{
		cfg:    cfg,
		logger: logger,
-		client: httpclient.NewLoggingClient(10*time.Second, logger),
+		client: httpclient.NewLoggingClient(time.Duration(cfg.HTTPTimeout)*time.Second, logger),
	}
}

@rksk rksk force-pushed the sftp-server-webooks branch from 9efa03b to 12bf920 Compare January 28, 2026 09:11
@rksk rksk force-pushed the sftp-server-webooks branch from 12bf920 to 5436778 Compare January 28, 2026 09:14
Copy link

@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: 4

🤖 Fix all issues with AI agents
In `@operations/sftpgo-authentication-service/.env.example`:
- Around line 35-36: The comment header is merged with its text and the
DB_CONN_STRING line lacks a trailing newline; separate and space the comment as
e.g. "# Database Connection (MySQL) Format: user:password@tcp(host:port)/dbname"
(or similar readable spacing) and ensure the
DB_CONN_STRING="user:password@tcp(127.0.0.1:3306)/sftpgo_sessions" line ends
with a newline (add a final line break at EOF) so tools that require trailing
newlines and clearer comments will work; reference DB_CONN_STRING to locate the
value to update and the comment above it.

In `@operations/sftpgo-authentication-service/internal/handler/handler.go`:
- Around line 185-187: The provisioning error is currently logged via
h.logger.Error after calling h.sftpgo.ProvisionFolders(folders) but the handler
continues and returns 200, which is inconsistent with the empty-folder 204 path;
change the handler so that if h.sftpgo.ProvisionFolders(folders) returns an
error you: (1) create an audit/error entry (use the same audit mechanism used
elsewhere), (2) log the error with context including u.Username and the error,
and (3) abort processing by returning a 500 response to the caller instead of
continuing to return 200; update the code around the h.sftpgo.ProvisionFolders
call (and remove the current non-fatal h.logger.Error-only behavior) so
provisioning failures are treated as fatal.

In `@operations/sftpgo-authentication-service/internal/httpclient/client.go`:
- Around line 55-74: The logRequest method on LoggingTransport currently uses
io.ReadAll on req.Body which can OOM for large payloads; change logRequest (and
the analogous trace logging in logResponse) to read at most 10KB for tracing by
using a LimitedReader or io.ReadFull into a fixed-size buffer, capture the
prefix for logging, then reconstruct req.Body (and resp.Body in the response
path) by concatenating the prefix buffer with the remaining unread stream so the
full body is preserved for transmission/consumption; ensure you handle nil
bodies and reset the Body to io.NopCloser over a combined reader after reading
the prefix.

In `@operations/sftpgo-authentication-service/internal/service/sftpgo.go`:
- Around line 60-63: In UpdateUser, separate the error path from the existence
boolean returned by checkFolderExists: call exists, err :=
s.checkFolderExists(projectKey, token) then if err != nil return
s.logger.Errorf(...) with a message like "failed to check folder '%s' before
user update: %v" including the err, and only if err == nil then if !exists
return s.logger.Errorf("folder '%s' does not exist, cannot update user"); mirror
the pattern used in ProvisionFolders to avoid masking transport/auth/status
errors when calling checkFolderExists.
🧹 Nitpick comments (20)
operations/sftpgo-authentication-service/Dockerfile (1)

33-33: Consider pinning the Alpine version for reproducible builds.

Using alpine:latest can lead to non-reproducible builds and unexpected behavior when the base image is updated.

♻️ Proposed fix
-FROM alpine:latest
+FROM alpine:3.19
operations/sftpgo-authentication-service/.env.example (1)

26-28: Clarify the distinction between FOLDER_PATH and DIR_PATH.

Both variables have the same placeholder value (/path/on/sftpgo/server), making it unclear what each is used for. Consider adding comments explaining their distinct purposes.

📝 Proposed improvement
 `#SFTPGo` User/Folder Configuration
-FOLDER_PATH="/path/on/sftpgo/server"
-DIR_PATH="/path/on/sftpgo/server"
+FOLDER_PATH="/path/on/sftpgo/server"   # Base path for virtual folders
+DIR_PATH="/path/on/sftpgo/server"      # Base path for user home directories
 CHECK_ROLE="internal" # The role display name to check for internal users
operations/sftpgo-authentication-service/README.md (2)

23-37: Add language specifier to fenced code block.

The architecture diagram code block lacks a language specifier. Use text or plaintext for ASCII diagrams.

📝 Proposed fix
-```
+```text
 ┌─────────────┐         ┌──────────────────┐         ┌─────────────────┐

41-62: Add language specifier to project structure code block.

📝 Proposed fix
-```
+```text
 .
 ├── cmd/server/main.go              # Application entry point
operations/sftpgo-authentication-service/internal/util/util.go (3)

42-45: Silently ignoring init error could mask issues.

While the default pattern is unlikely to fail compilation, ignoring the error silently makes debugging harder if a custom pattern with issues is somehow introduced.

📝 Proposed improvement
 func init() {
 	// Initialize with default pattern
-	_ = InitEmailRegex("")
+	if err := InitEmailRegex(""); err != nil {
+		panic("failed to compile default email regex: " + err.Error())
+	}
 }

52-58: Simplify IsLikelyEmail return statement.

The conditional can be reduced to a single return.

♻️ Proposed simplification
 // IsLikelyEmail checks if a string broadly resembles an email address.
 func IsLikelyEmail(s string) bool {
-	if !emailRegex.MatchString(s) {
-		return false
-	}
-	return true
+	return emailRegex.MatchString(s)
 }

33-40: Thread-safety concern if InitEmailRegex is called concurrently.

emailRegex is a package-level variable modified without synchronization. If InitEmailRegex is called from multiple goroutines, there's a data race. This is likely acceptable since initialization typically happens once at startup, but worth noting.

operations/sftpgo-authentication-service/internal/util/init_test.go (1)

41-49: Pattern duplication creates maintenance risk.

The pattern on line 43 is duplicated from util.go. If the default pattern changes in util.go, this test won't detect compilation issues with the actual pattern used.

Consider testing compilation through the exported InitEmailRegex function instead.

♻️ Proposed improvement
 func TestEmailRegexCompilation(t *testing.T) {
-	// Test that the refined simple default pattern compiles
-	pattern := `^[\p{L}0-9!#$'%*+=?^_{|}~&-]+(?:\.[\p{L}0-9!#$'%*+=?^_{|}~&-]+)*@[\p{L}0-9.\-_]+\.[a-zA-Z]{2,10}$`
-
-	_, err := regexp.Compile(pattern)
+	// Test that the default pattern compiles correctly
+	err := InitEmailRegex("")
 	if err != nil {
 		t.Fatalf("Default pattern failed to compile: %v", err)
 	}
 }
operations/sftpgo-authentication-service/internal/service/idp_test.go (1)

62-75: Verify case-sensitivity expectations for internal-user matching.
Email domains are generally case-insensitive; the test enforces a strict case match (e.g., user@WSO2.COM → false). If internal routing should be case-insensitive, normalize input (e.g., strings.ToLower) and update this test accordingly.

operations/sftpgo-authentication-service/internal/service/subscription_test.go (1)

70-98: Consider adding error handling tests.

The happy path tests are solid. For improved robustness, consider adding tests for error scenarios such as server timeouts, malformed JSON responses, or network failures. This would help ensure the service degrades gracefully.

operations/sftpgo-authentication-service/cmd/server/main.go (2)

71-77: Add ReadHeaderTimeout for complete slowloris mitigation.

While ReadTimeout, WriteTimeout, and IdleTimeout are now configured (addressing previous feedback), ReadHeaderTimeout is still missing. This timeout specifically limits how long the server waits for request headers and is the primary defense against slowloris attacks where attackers send headers very slowly.

🛡️ Suggested improvement
 server := &http.Server{
 	Addr:         ":" + cfg.Port,
 	Handler:      mux,
+	ReadHeaderTimeout: 5 * time.Second,
 	ReadTimeout:  time.Duration(cfg.ReadTimeout) * time.Second,
 	WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second,
 	IdleTimeout:  time.Duration(cfg.IdleTimeout) * time.Second,
 }

65-68: Consider adding a health check endpoint.

For operational readiness in Choreo/Kubernetes environments, a /health or /ready endpoint is typically beneficial for liveness and readiness probes.

💡 Example
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	w.Write([]byte("OK"))
})
operations/sftpgo-authentication-service/internal/config/config.go (2)

86-92: IsSensitive field is unused.

The EnvVar.IsSensitive field is defined but never utilized in validateEnvVars. If this is intended for future log masking or audit purposes, consider adding a TODO comment. Otherwise, it can be removed to reduce confusion.


193-208: Normalize base paths to prevent double-slash issues in computed endpoints.

If environment variables like SFTPGO_API_BASE or INTERNAL_IDP_BASE_PATH include trailing slashes, the computed endpoints will contain double slashes (e.g., https://api.example.com//oauth2/token), which may cause request failures depending on the backend server configuration.

🔧 Suggested fix
+	// Normalize base paths by removing trailing slashes
+	cfg.SFTPGoBasePath = strings.TrimRight(cfg.SFTPGoBasePath, "/")
+	cfg.InternalIdPBasePath = strings.TrimRight(cfg.InternalIdPBasePath, "/")
+	if cfg.ExternalIdPBasePath != "" {
+		cfg.ExternalIdPBasePath = strings.TrimRight(cfg.ExternalIdPBasePath, "/")
+	}
+
 	// Compute endpoints
 	cfg.AdminTokenEP = cfg.SFTPGoBasePath + "/token"

Add this normalization block before computing endpoints (around line 193).

operations/sftpgo-authentication-service/internal/service/sftpgo.go (1)

220-226: Use a clearer error for empty folder names.

io.ErrShortBuffer is misleading here; a dedicated error improves intent and debugging.

operations/sftpgo-authentication-service/internal/config/config_test.go (1)

25-45: Avoid process-wide env leakage across tests.

defer os.Clearenv() wipes the process env for subsequent tests. Consider snapshot+restore or per-test env helpers so other tests don’t inherit an empty env.

Also applies to: 90-112

operations/sftpgo-authentication-service/openapi.yaml (1)

299-304: Add maxItems to array fields to cap payload size.

Arrays like permissions items, answers, questions, and echos are unbounded; consider adding explicit maxItems limits to mitigate abuse. (CKV_OPENAPI_21)

Also applies to: 339-344, 359-369

operations/sftpgo-authentication-service/internal/service/subscription.go (1)

41-47: Use configured HTTP timeout for subscription calls.

The client timeout is hard-coded; reuse cfg.HTTPTimeout to align with the rest of the service and configuration.

♻️ Suggested change
-		client: httpclient.NewLoggingClient(10*time.Second, logger),
+		client: httpclient.NewLoggingClient(time.Duration(cfg.HTTPTimeout)*time.Second, logger),
operations/sftpgo-authentication-service/internal/service/database.go (1)

42-105: Align DB optionality with constructor behavior.

NewDBService fails when DB_CONN_STRING is empty, but SaveSession/GetSession/DeleteSession treat a nil DB as a non-fatal optional mode. Consider either making DB mandatory (remove nil-paths) or allowing a nil DBService when the connection string is unset to keep behavior consistent.

operations/sftpgo-authentication-service/internal/handler/utils.go (1)

158-163: Static analysis false positive - this is an authenticator ID, not an API key.

The string "QmFzaWNBdXRoZW50aWNhdG9yOkxPQ0FM" is base64-encoded "BasicAuthenticator:LOCAL", which is Asgardeo's identifier for the local basic authenticator. Consider adding a comment to clarify this for future maintainers.

📝 Suggested clarification
 	// Support BasicAuthenticator where username is mandatory
+	// "QmFzaWNBdXRoZW50aWNhdG9yOkxPQ0FM" is base64("BasicAuthenticator:LOCAL")
 	if selectedAuth.AuthenticatorID == "QmFzaWNBdXRoZW50aWNhdG9yOkxPQ0FM" {

Copy link

@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

🤖 Fix all issues with AI agents
In `@operations/sftpgo-authentication-service/internal/handler/handler.go`:
- Around line 106-110: The debug log in handler.go exposes the raw project key
when extracting parts from u.Username (variables providedProjectKey and
username) via h.logger.Debug; change this to redact or mask the project key
before logging (e.g., replace with "REDACTED" or show only a short prefix/suffix
or hashed value) and log the masked value instead of providedProjectKey while
still logging username, ensuring h.logger.Debug is called with the
maskedProjectKey variable rather than the raw providedProjectKey.
- Around line 190-195: The code currently proceeds to build vfs and perms from
the folders slice even when ProvisionFolders (and its validateFolderName)
returned an error; change the flow in handler.go so that after calling
ProvisionFolders you check its error and immediately return that error (or
otherwise abort) before iterating over folders to construct vfs, perms,
virtualPath and mappedPath (references: ProvisionFolders, validateFolderName,
folders, vfs, perms, generalFileMgtPermissions, h.cfg.FolderPath);
alternatively, run validateFolderName on each folder before using it in
filepath.Join and skip any invalid entries to ensure no unvalidated folder names
are used in path operations.

Copy link

@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: 3

🤖 Fix all issues with AI agents
In `@operations/sftpgo-authentication-service/internal/httpclient/client.go`:
- Around line 58-82: In logRequest and logResponse where io.ReadFull is used to
read into prefix, do not replace the body with an empty reader on non-EOF read
errors when n>0; instead reconstruct the body to include the bytes successfully
read (prefix[:n]) followed by the remaining unread stream using io.MultiReader
(preserve req.Body/resp.Body by wrapping bytes.NewReader(prefix[:n]) plus the
original reader), and update the Trace log to print prefix[:n] (indicating
partial read and the error) so partial data is not lost on read errors.

In `@operations/sftpgo-authentication-service/internal/service/sftpgo.go`:
- Around line 19-27: The code builds SFTPGo API URLs by raw concatenation of
username and folder path segments which can break on reserved characters; add
the net/url package to the imports and replace string concatenation for path
segments with url.PathEscape(...) when composing any API paths that include user
names or folder paths (e.g., the places where you currently append username or
home/folder variables into the request URL), ensuring all path segments are
escaped before joining into the final URL.

In `@operations/sftpgo-authentication-service/internal/util/util.go`:
- Around line 34-60: InitEmailRegex currently assigns emailRegex even when
regexp.Compile fails, leaving it nil and causing IsLikelyEmail to panic; change
InitEmailRegex to only set the package-level emailRegex after regexp.Compile
returns nil error (preserve existing default fallback behavior), and update
IsLikelyEmail to defensively check that emailRegex != nil before calling
MatchString (return false if nil). Reference: InitEmailRegex, emailRegex, and
IsLikelyEmail.
🧹 Nitpick comments (1)
operations/sftpgo-authentication-service/internal/util/util.go (1)

70-74: Use a clearer error for empty folder names.
io.ErrShortBuffer is misleading for validation; a descriptive error will be easier to handle and log.

♻️ Suggested tweak
 if name == "" {
-	return io.ErrShortBuffer // Reusing generic error for empty
+	return fmt.Errorf("invalid folder name: empty")
 }

Copy link

@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

🤖 Fix all issues with AI agents
In `@operations/sftpgo-authentication-service/internal/handler/utils.go`:
- Line 187: The call to h.db.SaveSession(session.RequestID,
models.SessionData{FlowID: session.FlowID, NextStep: idpResp.NextStep}) is not
checking for errors; add error handling similar to handleAuthStep1: capture the
returned error from h.db.SaveSession, log it (using the existing logger in the
handler), and return or propagate an appropriate handler error/response so the
flow stops on persistence failures. Locate the SaveSession invocation in
utils.go and wrap it with an if err := h.db.SaveSession(...); err != nil { /*
log and return error response */ } pattern consistent with other handler
functions.
- Around line 115-118: The call to h.db.SaveSession(...) ignores errors—update
the code around h.db.SaveSession(req.RequestID, models.SessionData{FlowID:
idpResp.FlowID, NextStep: idpResp.NextStep}) to capture the returned error, log
it with context (include req.RequestID and the session data), and return/fail
early from the enclosing handler function instead of continuing; ensure you
surface a suitable error response to the caller so downstream steps that expect
the session do not proceed when persistence fails.
🧹 Nitpick comments (5)
operations/sftpgo-authentication-service/internal/config/config.go (2)

86-92: IsSensitive field is defined but never used.

The EnvVar struct has an IsSensitive field that is populated in validateEnvVars but never read. This appears to be scaffolding for future functionality (e.g., masking sensitive values in logs). Consider either implementing the intended functionality or removing the unused field to avoid confusion.


127-163: Silent fallback on invalid timeout values is acceptable but could mask misconfigurations.

The timeout parsing blocks (e.g., HTTP_TIMEOUT, READ_TIMEOUT) silently fall back to defaults when parsing fails. While this is a reasonable approach for resilience, consider logging a warning when an invalid value is provided so operators can detect misconfigurations.

💡 Optional: Add warning for invalid values
 if timeoutStr := os.Getenv("HTTP_TIMEOUT"); timeoutStr != "" {
-	if timeout, err := strconv.Atoi(timeoutStr); err == nil && timeout > 0 {
+	timeout, err := strconv.Atoi(timeoutStr)
+	if err != nil || timeout <= 0 {
+		// Consider logging: fmt.Fprintf(os.Stderr, "Warning: invalid HTTP_TIMEOUT '%s', using default\n", timeoutStr)
+	} else {
 		cfg.HTTPTimeout = timeout
 	}
 }
operations/sftpgo-authentication-service/internal/service/idp.go (2)

97-106: SCIM filter escaping may be incomplete for special characters.

The current escaping only handles double quotes ("). SCIM filter syntax may also be affected by other special characters like backslashes. Consider a more comprehensive escaping approach.

💡 Suggested improvement
 asgUser := username
 if !strings.Contains(username, "/") {
 	asgUser = "DEFAULT/" + username
 }
-// Security: Escape quotes in username to prevent SCIM filter injection
-safeUsername := strings.ReplaceAll(asgUser, `"`, `\"`)
+// Security: Escape special characters to prevent SCIM filter injection
+safeUsername := strings.ReplaceAll(asgUser, `\`, `\\`)
+safeUsername = strings.ReplaceAll(safeUsername, `"`, `\"`)
 filter := fmt.Sprintf(`userName eq "%s"`, safeUsername)

185-214: Consider returning error on HTTP 4xx/5xx in PostToAuthnEndpoint.

Currently, when res.StatusCode >= 400, the method logs a warning but still attempts to unmarshal and return the response. While this may be intentional to capture IdP error details in idpResp.Error, callers might not consistently check for error conditions in the returned struct.

operations/sftpgo-authentication-service/internal/handler/handler.go (1)

279-294: API key comparison may be vulnerable to timing attacks.

The direct string comparison apiKey != h.cfg.HookAPIKey could potentially leak timing information. For security-critical API key validation, consider using constant-time comparison.

💡 Suggested improvement
+import "crypto/subtle"
+
 func (h *Handler) authenticate(r *http.Request, w http.ResponseWriter) bool {
 	if h.cfg.HookAPIKey == "" {
 		return true
 	}
 
 	apiKey := r.Header.Get("API-Key")
-	if apiKey != h.cfg.HookAPIKey {
+	if subtle.ConstantTimeCompare([]byte(apiKey), []byte(h.cfg.HookAPIKey)) != 1 {
 		h.logger.Warn("Unauthorized access attempt from %s: invalid or missing API key", r.RemoteAddr)

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.

1 participant