package api import ( "bytes" "context" "encoding/json" "io" "net/http" "net/url" "strings" ) // Integrations exposes the OpenSky plugin's settings to end users under a // three-layer cascade (superadmin/global → organization → user). Each of the // four settings resolves independently, top wins, and a blank field falls // through to the layer below: // // - global (L1): the plugin's config in plugins.json, set in the API Server panel. // - org (L2): pluginSettings on the caller's organization record (org admins). // - user (L3): pluginSettings on the caller's own user record. // // The OAuth2 client id + secret resolve as a *pair* from the highest layer that // supplies a client id, so credential halves are never mixed across layers. // Enablement is strictly per-user (L3) and gated by the global master switch. // // Secrets (and inherited client ids) are never returned to a lower-privileged // client: the effective config is resolved server-side and only masked values // leave the API. Live probes run server-side against the resolved config. const ( openSkyPlugin = "opensky" openSkySecretMask = "••••••••" ) // osConfig is one layer's OpenSky settings. type osConfig struct { ClientID string `json:"clientId"` ClientSecret string `json:"clientSecret"` Plan string `json:"plan"` Bbox string `json:"bbox"` } // osStored is what we persist per user/org under pluginSettings.opensky. type osStored struct { Config osConfig `json:"config"` // Enabled is the personal per-user opt-in (user layer). Default false. Enabled bool `json:"enabled"` // Disabled is the organization layer's off switch, stored inverted so that // absent == enabled: existing org records (which carry a legacy enabled:false // from earlier config saves) therefore read as enabled, avoiding a regression. // Only meaningful on the org record; ignored on user records. Disabled bool `json:"disabled,omitempty"` } // osSettingsDoc is the pluginSettings JSON shape (only opensky today). type osSettingsDoc struct { OpenSky osStored `json:"opensky"` } // osFieldView is one field's resolved state for the UI. type osFieldView struct { Effective string `json:"effective"` // resolved value in force (secrets/inherited creds masked) Own string `json:"own"` // the caller's own editable-layer value (secret masked) Source string `json:"source"` // global | org | user | unset Locked bool `json:"locked"` // set above the caller's editable layer } // osResolution is the fully-resolved OpenSky state for one caller. It captures the // effective cascade plus both editable layers (personal + organization), so an org // admin can manage each independently — their own settings as a user, and the // organization-wide settings that override every user's. type osResolution struct { eff osConfig // effective (unmasked) — used only server-side (probes) userOwn osConfig // caller's personal (L3) values (unmasked) orgOwn osConfig // organization (L2) values (unmasked) source map[string]string // field -> layer name (global|org|user|unset) isSuper bool // superadmin: manages the global layer in the panel canOrg bool // caller may edit the organization layer (org admin) available bool // global master switch orgEnabled bool // org master switch (default true; gates the org's users) allowAnon bool // global anonymous policy enabled bool // caller's personal enable flag } // resolveOpenSky computes the cascade for a caller. userRaw is the caller's // pluginSettings blob (from their auth-refresh record). func (s *Server) resolveOpenSky(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) osResolution { g, masterEnabled, _ := s.plugins.RawConfig(openSkyPlugin) gc := osConfig{ClientID: g["clientId"], ClientSecret: g["clientSecret"], Plan: g["plan"], Bbox: g["bbox"]} allowAnon := !strings.EqualFold(strings.TrimSpace(g["allowAnonymous"]), "false") var oStored osStored if who.OrgID != "" { oStored, _ = s.orgOpenSky(ctx, who.OrgID) } oc := oStored.Config var uStored osStored if len(userRaw) > 0 { var d osSettingsDoc _ = json.Unmarshal(userRaw, &d) uStored = d.OpenSky } uc := uStored.Config res := osResolution{ source: map[string]string{}, userOwn: uc, orgOwn: oc, isSuper: who.isSuperadmin(), // An org admin may edit the organization layer in addition to their own // personal layer. Requires the service account (org writes go through it); // without it the org layer is invisible to the cascade anyway. canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(), available: masterEnabled, // Org gate: enabled by default, off only when the org explicitly disabled it. orgEnabled: !oStored.Disabled, allowAnon: allowAnon, enabled: uStored.Enabled, } // Ordered layers, top (highest priority) first. type layer struct { name string c osConfig } layers := []layer{{"global", gc}} if who.OrgID != "" { layers = append(layers, layer{"org", oc}) } layers = append(layers, layer{"user", uc}) pick := func(get func(osConfig) string) (val, src string) { for _, l := range layers { if v := strings.TrimSpace(get(l.c)); v != "" { return v, l.name } } return "", "unset" } res.eff.Plan, res.source["plan"] = pick(func(c osConfig) string { return c.Plan }) res.eff.Bbox, res.source["bbox"] = pick(func(c osConfig) string { return c.Bbox }) // Credentials resolve as a pair from the highest layer with a client id. credSrc := "unset" for _, l := range layers { if id := strings.TrimSpace(l.c.ClientID); id != "" { res.eff.ClientID, res.eff.ClientSecret, credSrc = id, l.c.ClientSecret, l.name break } } res.source["clientId"] = credSrc res.source["clientSecret"] = credSrc return res } // layerRank orders the cascade layers; a higher number is lower priority. var layerRank = map[string]int{"global": 1, "org": 2, "user": 3} // lockedFor reports whether a field whose value comes from source is locked for a // caller whose editable layer is editable (i.e. the value is set above them). func lockedFor(source, editable string) bool { if editable == "none" { return true // superadmin edits the global layer in the panel, not here } sr, ok := layerRank[source] if !ok { return false // unset — the caller may be the first to set it } return sr < layerRank[editable] } // maskPresent returns the secret mask when v is non-empty, else "". func maskPresent(v string) string { if strings.TrimSpace(v) != "" { return openSkySecretMask } return "" } // orgOpenSky reads an organization's stored OpenSky settings (config + the org // gate) and its raw pluginSettings blob via the service account. Best effort: zero // values on any miss so callers can proceed as if the org layer were empty. func (s *Server) orgOpenSky(ctx context.Context, orgID string) (osStored, json.RawMessage) { if orgID == "" || !s.admin.configured() { return osStored{}, nil } data, status, err := s.admin.do(ctx, http.MethodGet, "/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil) if err != nil || status != http.StatusOK { return osStored{}, nil } var rec struct { PluginSettings json.RawMessage `json:"pluginSettings"` } _ = json.Unmarshal(data, &rec) var doc osSettingsDoc if len(rec.PluginSettings) > 0 { _ = json.Unmarshal(rec.PluginSettings, &doc) } return doc.OpenSky, rec.PluginSettings } // mergeOpenSky applies a mutation to the opensky entry of a pluginSettings blob, // preserving any other plugin keys, and returns the new blob. func mergeOpenSky(existing json.RawMessage, apply func(*osStored)) json.RawMessage { doc := map[string]json.RawMessage{} if len(existing) > 0 { _ = json.Unmarshal(existing, &doc) } if doc == nil { doc = map[string]json.RawMessage{} // existing was JSON null } var os osStored if raw, ok := doc["opensky"]; ok { _ = json.Unmarshal(raw, &os) } apply(&os) b, _ := json.Marshal(os) doc["opensky"] = b out, _ := json.Marshal(doc) return out } // callerFromRecord builds an identity from an auth-refresh record. func callerFromRecord(rec *pbAuthResp) *callerIdentity { role := unquote(rec.Record["role"]) if role == "" { role = roleUser } return &callerIdentity{ ID: unquote(rec.Record["id"]), Email: unquote(rec.Record["email"]), Role: role, OrgID: unquote(rec.Record["organization"]), } } // GET /api/integrations/opensky — resolved OpenSky view for the caller. func (s *Server) handleGetOpenSky(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("Authorization") if token == "" { writeError(w, http.StatusUnauthorized, "missing token") return } rec, status, err := s.pbAuthRefresh(r.Context(), token) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if status != http.StatusOK || rec == nil { writeError(w, http.StatusUnauthorized, "invalid or expired token") return } who := callerFromRecord(rec) res := s.resolveOpenSky(r.Context(), who, rec.Record["pluginSettings"]) writeJSON(w, http.StatusOK, s.openSkyView(who, res)) } // osScopeView builds the masked field set for one editable scope. editable is the // layer the caller edits in this scope ("user" | "org" | "none"); a field is locked // when its effective value is set above that layer. func (s *Server) osScopeView(res osResolution, editable string) map[string]any { own := res.userOwn if editable == "org" { own = res.orgOwn } field := func(key, eff, ownv string, secret bool) osFieldView { src := res.source[key] locked := lockedFor(src, editable) fv := osFieldView{Source: src, Locked: locked} switch { case secret: // Never expose a secret; show only presence. fv.Effective, fv.Own = maskPresent(eff), maskPresent(ownv) case key == "clientId" && locked: // Inherited client id — hide the concrete value from a lower layer. fv.Effective, fv.Own = maskPresent(eff), maskPresent(ownv) default: fv.Effective, fv.Own = eff, ownv } return fv } return map[string]any{ "editableLayer": editable, "fields": map[string]osFieldView{ "clientId": field("clientId", res.eff.ClientID, own.ClientID, false), "clientSecret": field("clientSecret", res.eff.ClientSecret, own.ClientSecret, true), "plan": field("plan", res.eff.Plan, own.Plan, false), "bbox": field("bbox", res.eff.Bbox, own.Bbox, false), }, } } // openSkyView builds the masked, client-safe response body from a resolution. It // exposes a "user" scope for everyone plus, for org admins, an "org" scope — each // with its own locked-field state — so the UI can present the two independently. func (s *Server) openSkyView(who *callerIdentity, res osResolution) map[string]any { out := map[string]any{ "available": res.available, "orgEnabled": res.orgEnabled, "allowAnonymous": res.allowAnon, "enabled": res.enabled, "role": who.Role, "orgId": who.OrgID, "canEditOrg": res.canOrg, "isSuperadmin": res.isSuper, } if res.isSuper { // Superadmin manages the global layer in the panel; here it is read-only. out["editableLayer"] = "none" out["scopes"] = map[string]any{"user": s.osScopeView(res, "none")} return out } scopes := map[string]any{"user": s.osScopeView(res, "user")} if res.canOrg { scopes["org"] = s.osScopeView(res, "org") } out["scopes"] = scopes return out } // PUT /api/integrations/opensky — save the caller's editable layer. Body: // {enabled?: bool, config?: {clientId, clientSecret, plan, bbox}}. Fields locked // above the caller are ignored; a client secret left at the mask is preserved. func (s *Server) handlePutOpenSky(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("Authorization") if token == "" { writeError(w, http.StatusUnauthorized, "missing token") return } var body struct { Enabled *bool `json:"enabled"` Scope string `json:"scope"` // "user" (default) | "org" (admins only) Config map[string]string `json:"config"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } rec, status, err := s.pbAuthRefresh(r.Context(), token) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if status != http.StatusOK || rec == nil { writeError(w, http.StatusUnauthorized, "invalid or expired token") return } who := callerFromRecord(rec) userRaw := rec.Record["pluginSettings"] res := s.resolveOpenSky(r.Context(), who, userRaw) // Resolve which layer this write targets. Everyone edits their own personal // (user) layer by default; an org admin may target the organization layer by // asking for scope "org". Superadmins are read-only here (they manage global // in the panel) and may only toggle their personal enable flag. editable := "user" switch { case res.isSuper: editable = "none" case strings.EqualFold(strings.TrimSpace(body.Scope), "org"): if !res.canOrg { writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings") return } editable = "org" } // Build the new target-layer config from its current own values, overlaying // only fields the caller is allowed to change in this scope. newOwn := res.userOwn if editable == "org" { newOwn = res.orgOwn } applyField := func(key string, set func(*osConfig, string)) { v, ok := body.Config[key] if !ok || lockedFor(res.source[key], editable) { return } if key == "clientSecret" && v == openSkySecretMask { return // keep current secret } set(&newOwn, strings.TrimSpace(v)) } applyField("plan", func(c *osConfig, v string) { c.Plan = v }) applyField("bbox", func(c *osConfig, v string) { c.Bbox = v }) applyField("clientId", func(c *osConfig, v string) { c.ClientID = v }) // Secret is not trimmed (may legitimately contain edge whitespace? no — trim // for consistency with the panel's Upsert, which TrimSpaces all values). applyField("clientSecret", func(c *osConfig, v string) { c.ClientSecret = v }) // Persist the organization layer (admins) via the service account. if editable == "org" { if who.OrgID == "" { writeError(w, http.StatusForbidden, "your account is not attached to an organization") return } if !s.admin.configured() { writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server") return } _, orgRaw := s.orgOpenSky(r.Context(), who.OrgID) newDoc := mergeOpenSky(orgRaw, func(os *osStored) { os.Config = newOwn // In the org scope the enable flag is the org master switch, stored // inverted (disabled) so absent means enabled. if body.Enabled != nil { os.Disabled = !*body.Enabled } }) _, st, err := s.admin.do(r.Context(), http.MethodPatch, "/api/collections/organizations/records/"+url.PathEscape(who.OrgID), map[string]json.RawMessage{"pluginSettings": newDoc}) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if st != http.StatusOK { writeError(w, http.StatusBadGateway, "could not save organization settings") return } } // Persist the user record: the personal enable flag lives here (user/superadmin // scope — in the org scope it targets the org gate instead), and so does the // personal config layer when this write targets the user scope. personalEnable := body.Enabled != nil && editable != "org" if personalEnable || editable == "user" { newDoc := mergeOpenSky(userRaw, func(os *osStored) { if personalEnable { os.Enabled = *body.Enabled } if editable == "user" { os.Config = newOwn } }) if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } else if code != http.StatusOK { writeError(w, http.StatusBadGateway, "could not save user settings") return } } // Re-resolve and return the fresh view. fresh, st, err := s.pbAuthRefresh(r.Context(), token) if err != nil || st != http.StatusOK || fresh == nil { // The writes succeeded; just report success minimally. writeJSON(w, http.StatusOK, map[string]any{"ok": true}) return } res2 := s.resolveOpenSky(r.Context(), who, fresh.Record["pluginSettings"]) writeJSON(w, http.StatusOK, s.openSkyView(who, res2)) } // patchUserPluginSettings writes the pluginSettings blob onto the caller's own // user record using their token (PocketBase authorises self-writes). func (s *Server) patchUserPluginSettings(ctx context.Context, token, id string, doc json.RawMessage) (int, error) { if id == "" { return 0, io.EOF // treated as a transport-ish failure by the caller } patch, _ := json.Marshal(map[string]json.RawMessage{"pluginSettings": doc}) req, _ := http.NewRequestWithContext(ctx, http.MethodPatch, s.auth.url()+"/api/collections/users/records/"+url.PathEscape(id), bytes.NewReader(patch)) req.Header.Set("Authorization", token) req.Header.Set("Content-Type", "application/json") resp, err := s.auth.client.Do(req) if err != nil { return 0, err } defer resp.Body.Close() _, _ = io.Copy(io.Discard, resp.Body) return resp.StatusCode, nil } // POST /api/integrations/opensky/health — live probe using the caller's resolved // config. Never returns secrets. func (s *Server) handleOpenSkyHealth(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("Authorization") if token == "" { writeError(w, http.StatusUnauthorized, "missing token") return } rec, status, err := s.pbAuthRefresh(r.Context(), token) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if status != http.StatusOK || rec == nil { writeError(w, http.StatusUnauthorized, "invalid or expired token") return } who := callerFromRecord(rec) if !who.isSuperadmin() { if _, _, ok := s.plugins.RawConfig(openSkyPlugin); !ok { writeError(w, http.StatusNotFound, "unknown plugin") return } } res := s.resolveOpenSky(r.Context(), who, rec.Record["pluginSettings"]) if !res.available { writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ "status": "down", "detail": "OpenSky is disabled by the administrator"}}) return } if !res.orgEnabled { writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ "status": "down", "detail": "OpenSky is disabled for your organization"}}) return } cfg := map[string]string{ "clientId": res.eff.ClientID, "clientSecret": res.eff.ClientSecret, "plan": res.eff.Plan, "bbox": res.eff.Bbox, "allowAnonymous": boolStr(res.allowAnon), } h, err := s.plugins.HealthCheckWith(r.Context(), openSkyPlugin, cfg) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"health": h}) } func boolStr(b bool) string { if b { return "true" } return "false" }