Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ghcr-k3s-cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ jobs:
spec:
nodeSelector:
kubernetes.io/arch: amd64
kubernetes.io/hostname: hdtbcs-ovh1
imagePullSecrets:
- name: ${PULL_SECRET}
containers:
Expand Down Expand Up @@ -379,6 +380,7 @@ jobs:
spec:
nodeSelector:
kubernetes.io/arch: amd64
kubernetes.io/hostname: hdtbcs-ovh1
imagePullSecrets:
- name: ${PULL_SECRET}
containers:
Expand Down
9 changes: 9 additions & 0 deletions assets/brand/built with HollowData's tech.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
289 changes: 289 additions & 0 deletions basaltpass-backend/cmd/infopipe-seed/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
package main

import (
"basaltpass-backend/internal/common"
config "basaltpass-backend/internal/config"
migration "basaltpass-backend/internal/migration"
"basaltpass-backend/internal/model"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"time"

"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)

type serviceDef struct {
ID string
Name string
Description string
BaseURL string
Scopes []string
}

type seededClient struct {
AppID uint `json:"app_id"`
Name string `json:"name"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret,omitempty"`
Scopes []string `json:"scopes"`
}

type report struct {
TenantID uint `json:"tenant_id"`
TenantCode string `json:"tenant_code"`
AdminUserID uint `json:"admin_user_id"`
Clients []seededClient `json:"clients"`
CrossAppTrusts []string `json:"cross_app_trusts"`
EnvPath string `json:"env_path"`
SeededAt time.Time `json:"seeded_at"`
}

func main() {
var (
configPath = flag.String("config", "", "optional BasaltPass config path")
envPath = flag.String("env-out", "", "optional env output path")
)
flag.Parse()

if _, err := config.Load(*configPath); err != nil {
log.Fatalf("load config: %v", err)
}
if err := migration.RunMigrations(); err != nil {
log.Fatalf("run migrations: %v", err)
}

outPath := strings.TrimSpace(*envPath)
if outPath == "" {
outPath = filepath.Clean(filepath.Join("..", "..", "integration", "secrets", "basaltpass.infopipe.env"))
}

rep, err := seed(common.DB(), outPath)
if err != nil {
log.Fatalf("seed infopipe apps: %v", err)
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(rep); err != nil {
log.Fatalf("encode report: %v", err)
}
}

func seed(db *gorm.DB, envPath string) (*report, error) {
if db == nil {
return nil, fmt.Errorf("database is not available")
}
now := time.Now().UTC()
tenant := model.Tenant{
Name: "Info Pipeline",
Code: "infopipe",
Description: "Local integration tenant for the information pipeline.",
Status: model.TenantStatusActive,
Metadata: model.JSONMap{"managed_by": "infopipe-seed"},
}
if err := db.Where("code = ?", tenant.Code).Assign(tenant).FirstOrCreate(&tenant).Error; err != nil {
return nil, fmt.Errorf("upsert tenant: %w", err)
}

admin := model.User{
Email: "infopipe-admin@basalt.local",
PasswordHash: mustHash("InfoPipe@12345"),
Nickname: "infopipe-admin",
EmailVerified: true,
}
isAdmin := true
admin.IsSystemAdmin = &isAdmin
if err := db.Where("email = ? AND enforced_tenant_id = ?", admin.Email, 0).Assign(admin).FirstOrCreate(&admin).Error; err != nil {
return nil, fmt.Errorf("upsert admin user: %w", err)
}
if err := db.Where("tenant_id = ? AND user_id = ?", tenant.ID, admin.ID).
Assign(model.TenantUser{Role: model.TenantRoleOwner}).
FirstOrCreate(&model.TenantUser{TenantID: tenant.ID, UserID: admin.ID, Role: model.TenantRoleOwner}).Error; err != nil {
return nil, fmt.Errorf("upsert tenant owner: %w", err)
}

services := []serviceDef{
{"orion", "Orion", "Mission control and review service.", "http://orion:8120", []string{"s2s.read", "s2s.token_exchange", "apicred.llm", "llm.plan_mission", "llm.generate_product_spec", "llm.review_artifact", "llm.summarize_evidence"}},
{"vesper", "Vesper", "Analysis and brief generation service.", "http://vesper:8121", []string{"s2s.read", "s2s.token_exchange", "apicred.llm", "llm.extract_entities", "llm.extract_claims", "llm.summarize_event", "llm.generate_brief"}},
{"cis", "CIS", "Source planning service.", "http://cis:8195", []string{"s2s.read", "s2s.token_exchange", "apicred.llm", "cis.source_plan", "araneae.read", "araneae.write", "hashslip.read", "hashslip.write"}},
{"docode", "DoCode", "Crawler and code generation service.", "http://docode:8110", []string{"s2s.read", "s2s.token_exchange", "docode.jobs", "docode.write", "llm", "apicred.read"}},
{"hashslip", "HashSlip", "Fact and artifact store.", "http://hashslip:8106", []string{"s2s.read", "hashslip.read", "hashslip.write"}},
{"araneae", "Araneae", "Crawler execution service.", "http://araneae-control:8180", []string{"s2s.read", "araneae.read", "araneae.write", "hashslip.write"}},
{"apicred", "APICred", "LLM capability and provider gateway.", "http://apicred:8103", []string{"s2s.read", "llm", "apicred.read", "llm.gateway", "llm.grants"}},
{"atlas", "Atlas", "Graph projection service.", "http://atlas:8130", []string{"s2s.read", "hashslip.read", "atlas.read"}},
{"forge", "Forge", "Replay and evaluation service.", "http://forge:8150", []string{"s2s.read", "hashslip.read", "forge.eval"}},
{"dispatch", "Dispatch", "Artifact delivery service.", "http://dispatch:8140", []string{"s2s.read", "hashslip.read", "atlas.read", "dispatch.read"}},
}

appIDs := map[string]uint{}
clients := []seededClient{}
for _, svc := range services {
app := model.App{
TenantID: tenant.ID,
Name: svc.Name,
Description: svc.Description,
HomepageURL: svc.BaseURL,
Status: model.AppStatusActive,
IsVerified: true,
}
if err := db.Where("tenant_id = ? AND name = ?", tenant.ID, svc.Name).Assign(app).FirstOrCreate(&app).Error; err != nil {
return nil, fmt.Errorf("upsert app %s: %w", svc.ID, err)
}
appIDs[svc.ID] = app.ID
appUser := model.AppUser{
AppID: app.ID,
UserID: admin.ID,
FirstAuthorizedAt: now,
LastAuthorizedAt: now,
Scopes: strings.Join(svc.Scopes, " "),
Status: model.AppUserStatusActive,
}
if err := db.Where("app_id = ? AND user_id = ?", app.ID, admin.ID).
Assign(appUser).
FirstOrCreate(&model.AppUser{AppID: app.ID, UserID: admin.ID, FirstAuthorizedAt: now, LastAuthorizedAt: now, Scopes: appUser.Scopes, Status: model.AppUserStatusActive}).Error; err != nil {
return nil, fmt.Errorf("authorize admin for app %s: %w", svc.ID, err)
}

secret := "bp-infopipe-" + svc.ID + "-secret"
client := model.OAuthClient{
AppID: app.ID,
ClientID: "infopipe-" + svc.ID,
ClientSecret: secret,
TokenEndpointAuthMethod: model.OAuthTokenEndpointAuthClientSecretPost,
SubjectType: model.OAuthSubjectTypePublic,
RedirectURIs: "http://localhost/callback",
Scopes: strings.Join(svc.Scopes, ","),
GrantTypes: "client_credentials,authorization_code,refresh_token,urn:ietf:params:oauth:grant-type:token-exchange",
IsActive: true,
AllowedOrigins: "http://localhost",
CreatedBy: admin.ID,
}
client.HashClientSecret()
var existing model.OAuthClient
if err := db.Where("client_id = ?", client.ClientID).First(&existing).Error; err == nil {
client.ID = existing.ID
client.Model = existing.Model
if err := db.Model(&existing).Updates(map[string]any{
"app_id": client.AppID,
"client_secret": client.ClientSecret,
"token_endpoint_auth_method": client.TokenEndpointAuthMethod,
"subject_type": client.SubjectType,
"redirect_uris": client.RedirectURIs,
"scopes": client.Scopes,
"grant_types": client.GrantTypes,
"is_active": true,
"allowed_origins": client.AllowedOrigins,
"created_by": admin.ID,
}).Error; err != nil {
return nil, fmt.Errorf("update client %s: %w", client.ClientID, err)
}
} else if err == gorm.ErrRecordNotFound {
if err := db.Create(&client).Error; err != nil {
return nil, fmt.Errorf("create client %s: %w", client.ClientID, err)
}
} else {
return nil, fmt.Errorf("lookup client %s: %w", client.ClientID, err)
}
clients = append(clients, seededClient{
AppID: app.ID,
Name: svc.Name,
ClientID: "infopipe-" + svc.ID,
ClientSecret: secret,
Scopes: svc.Scopes,
})
}

trustPairs := [][2]string{
{"orion", "cis"}, {"orion", "vesper"}, {"orion", "hashslip"}, {"orion", "apicred"}, {"orion", "dispatch"}, {"orion", "atlas"},
{"vesper", "hashslip"}, {"vesper", "apicred"}, {"vesper", "atlas"},
{"cis", "araneae"}, {"cis", "hashslip"}, {"cis", "apicred"}, {"cis", "docode"},
{"docode", "apicred"},
{"araneae", "hashslip"},
{"forge", "orion"}, {"forge", "vesper"}, {"forge", "hashslip"}, {"forge", "atlas"},
{"dispatch", "hashslip"}, {"dispatch", "atlas"},
}
trustNames := []string{}
for _, pair := range trustPairs {
sourceID, targetID := appIDs[pair[0]], appIDs[pair[1]]
trust := model.CrossAppTrust{
TenantID: tenant.ID,
SourceAppID: sourceID,
TargetAppID: targetID,
AllowedScopes: "s2s.read,s2s.token_exchange,apicred.llm,llm,apicred.read,docode.jobs,docode.write,hashslip.read,hashslip.write,araneae.read,araneae.write,llm.gateway,llm.grants,atlas.read,forge.eval,dispatch.read",
MaxTokenTTL: 3600,
Description: "Info pipeline local integration trust: " + pair[0] + " -> " + pair[1],
IsActive: true,
CreatedBy: admin.ID,
}
if err := db.Where("tenant_id = ? AND source_app_id = ? AND target_app_id = ?", tenant.ID, sourceID, targetID).
Assign(trust).FirstOrCreate(&trust).Error; err != nil {
return nil, fmt.Errorf("upsert trust %s->%s: %w", pair[0], pair[1], err)
}
trustNames = append(trustNames, pair[0]+"->"+pair[1])
}
sort.Strings(trustNames)

rep := &report{
TenantID: tenant.ID,
TenantCode: tenant.Code,
AdminUserID: admin.ID,
Clients: clients,
CrossAppTrusts: trustNames,
EnvPath: envPath,
SeededAt: now,
}
if err := writeEnv(envPath, clients); err != nil {
return nil, err
}
return rep, nil
}

func writeEnv(path string, clients []seededClient) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create env dir: %w", err)
}
values := map[string]string{
"AUTH_PROVIDER_MODE": "real",
"BASALTPASS_PROVIDER_MODE": "real",
"BASALTPASS_BASE_URL": "http://127.0.0.1:8101",
"BASALTPASS_DOCKER_URL": "http://host.docker.internal:8101",
"USE_MOCK_BASALTPASS": "false",
"APICRED_REQUIRE_REAL_PROVIDER": "false",
}
for _, client := range clients {
key := strings.ToUpper(strings.ReplaceAll(client.Name, " ", "_"))
values[key+"_CLIENT_ID"] = client.ClientID
values[key+"_CLIENT_SECRET"] = client.ClientSecret
}
order := make([]string, 0, len(values))
for key := range values {
order = append(order, key)
}
sort.Strings(order)
var b strings.Builder
b.WriteString("# Generated by BasaltPass infopipe-seed. Local integration credentials only.\n")
for _, key := range order {
b.WriteString(key)
b.WriteString("=")
b.WriteString(values[key])
b.WriteString("\n")
}
if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
return fmt.Errorf("write env: %w", err)
}
return nil
}

func mustHash(password string) string {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
return string(hash)
}
1 change: 1 addition & 0 deletions basaltpass-backend/config/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ settings:
- openid
- profile
- email
- groups
- offline_access
- s2s.read
- s2s.user.read
Expand Down
3 changes: 3 additions & 0 deletions basaltpass-backend/internal/api/v1/routes/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
publicSettings "basaltpass-backend/internal/handler/public/settings"
"basaltpass-backend/internal/handler/public/signup"
publicTenant "basaltpass-backend/internal/handler/public/tenant"
"basaltpass-backend/internal/handler/public/trust"
"basaltpass-backend/internal/middleware/ratelimit"

"github.com/gofiber/fiber/v2"
Expand All @@ -18,6 +19,8 @@ func InitPublicRouteDependencies(_ *gorm.DB) {}

// RegisterPublicRoutes 注册公开路由(无需认证)
func RegisterPublicRoutes(v1 fiber.Router) {
v1.Post("/trust/token", trust.IssueTrustTokenHandler)

// 健康检查端点
v1.Get("/health", func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
Expand Down
2 changes: 2 additions & 0 deletions basaltpass-backend/internal/handler/public/oauth/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ func DiscoveryHandler(c *fiber.Ctx) error {
"email",
"address",
"phone",
"groups",
"offline_access",
},
ResponseTypesSupported: []string{
Expand Down Expand Up @@ -141,6 +142,7 @@ func DiscoveryHandler(c *fiber.Ctx) error {
"birthdate",
"picture",
"preferred_username",
"groups",
"locale",
"zoneinfo",
"updated_at",
Expand Down
16 changes: 11 additions & 5 deletions basaltpass-backend/internal/handler/public/oauth/oidc_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func setupOIDCE2EDB(t *testing.T) *gorm.DB {
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.Tenant{}, &model.TenantUser{}, &model.Gender{}, &model.UserProfile{}, &model.App{}, &model.AppUser{}, &model.OAuthClient{}, &model.OAuthAuthorizationCode{}, &model.OAuthAccessToken{}, &model.OAuthRefreshToken{}, &model.OIDCSigningKey{}); err != nil {
if err := db.AutoMigrate(&model.User{}, &model.Role{}, &model.UserRole{}, &model.Tenant{}, &model.TenantUser{}, &model.TenantRbacRole{}, &model.TenantUserRbacRole{}, &model.Gender{}, &model.UserProfile{}, &model.App{}, &model.AppUser{}, &model.OAuthClient{}, &model.OAuthAuthorizationCode{}, &model.OAuthAccessToken{}, &model.OAuthRefreshToken{}, &model.OIDCSigningKey{}); err != nil {
t.Fatalf("auto migrate failed: %v", err)
}
common.SetDBForTest(db)
Expand All @@ -62,6 +62,8 @@ func TestOIDCAuthCodePKCEIssuesIDTokenAndJWKSVerifies(t *testing.T) {
AvatarURL: "https://rp.example/avatar.png",
EmailVerified: true,
}
isSystemAdmin := true
user.IsSystemAdmin = &isSystemAdmin
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
Expand All @@ -74,15 +76,15 @@ func TestOIDCAuthCodePKCEIssuesIDTokenAndJWKSVerifies(t *testing.T) {
}
client := model.OAuthClient{AppID: app.ID, ClientID: "oidc-client", ClientSecret: "oidc-secret", RedirectURIs: "https://rp.example/callback", IsActive: true}
client.HashClientSecret()
client.SetScopeList([]string{"openid", "profile", "email", "offline_access"})
client.SetScopeList([]string{"openid", "profile", "email", "groups", "offline_access"})
if err := db.Create(&client).Error; err != nil {
t.Fatalf("create client: %v", err)
}

svc := NewOAuthServerService()
verifier := "verifier-123"
challenge := base64.RawURLEncoding.EncodeToString(sha256sum(verifier))
authReq := &AuthorizeRequest{ClientID: client.ClientID, RedirectURI: "https://rp.example/callback", ResponseType: "code", Scope: "openid profile email offline_access", CodeChallenge: challenge, CodeChallengeMethod: "S256", Nonce: "nonce-xyz"}
authReq := &AuthorizeRequest{ClientID: client.ClientID, RedirectURI: "https://rp.example/callback", ResponseType: "code", Scope: "openid profile email groups offline_access", CodeChallenge: challenge, CodeChallengeMethod: "S256", Nonce: "nonce-xyz"}
code, err := svc.GenerateAuthorizationCode(user.ID, authReq, &client)
if err != nil {
t.Fatalf("generate code: %v", err)
Expand Down Expand Up @@ -124,10 +126,14 @@ func TestOIDCAuthCodePKCEIssuesIDTokenAndJWKSVerifies(t *testing.T) {
t.Fatalf("expected email claims in id_token: email=%v email_verified=%v", claims["email"], claims["email_verified"])
}
for _, claim := range []string{"name", "preferred_username", "given_name", "family_name", "middle_name", "picture"} {
if _, exists := claims[claim]; exists {
t.Fatalf("code-flow id_token should leave %s to userinfo, claims=%v", claim, claims)
if _, exists := claims[claim]; !exists {
t.Fatalf("code-flow id_token should include %s for automatic RP onboarding, claims=%v", claim, claims)
}
}
groups := stringSliceClaim(claims["groups"])
if !containsString(groups, "basaltpass-system-admin") || !containsString(groups, "tenant:oidc:member") {
t.Fatalf("expected system and tenant groups in id_token, groups=%v", groups)
}
if claims["acr"] == "" || len(stringSliceClaim(claims["amr"])) == 0 {
t.Fatalf("expected acr/amr claims: acr=%v amr=%v", claims["acr"], claims["amr"])
}
Expand Down
Loading
Loading