mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* feat(api): establish public v1 contract foundation Co-authored-by: multica-agent <[email protected]> * fix(plugins): close public API contract gaps Co-authored-by: multica-agent <[email protected]> * fix(issues): enforce revision guard during update Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
246 lines
9.2 KiB
Go
246 lines
9.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// Two credentials, moving in opposite directions.
|
|
//
|
|
// Neither one ever enters an iframe: a surface still holds nothing and still
|
|
// reaches Multica only by asking the host page over postMessage. What changes
|
|
// with hooks is that a plugin now has a SERVER, and that server needs a way to
|
|
// be recognised. So the honest statement about the system is no longer "there
|
|
// are no plugin credentials" but "plugin credentials only move between
|
|
// servers".
|
|
//
|
|
// install token (mpi_…) plugin -> host, long-lived, rotatable.
|
|
// The host only ever verifies it, so it is stored
|
|
// hashed and cannot be recovered from the database.
|
|
// callback token host -> plugin, minutes, one INVOCATION.
|
|
// Handed to a hook handler so it can answer using the
|
|
// Action API without being given standing access.
|
|
|
|
const (
|
|
installTokenPrefix = "mpi_"
|
|
callbackTokenPrefix = "mpc_"
|
|
callbackTokenTTL = 5 * time.Minute
|
|
callbackTokenEntropy = 32
|
|
)
|
|
|
|
// IssueInstallToken mints a new install token and stores only its hash.
|
|
//
|
|
// Returned in plaintext exactly once. There is no endpoint that reads it back —
|
|
// an admin who loses it rotates rather than recovers, which is the same trade
|
|
// every other bearer credential in the product makes.
|
|
func (s *PluginService) IssueInstallToken(ctx context.Context, installationID pgtype.UUID) (string, error) {
|
|
raw := make([]byte, callbackTokenEntropy)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return "", &PluginError{Kind: PluginErrorUnavailable, Message: "generate install token", Err: err}
|
|
}
|
|
token := installTokenPrefix + base64.RawURLEncoding.EncodeToString(raw)
|
|
if err := s.Queries.SetPluginInstallationToken(ctx, db.SetPluginInstallationTokenParams{
|
|
ID: installationID,
|
|
TokenHash: pgtype.Text{String: hashToken(token), Valid: true},
|
|
}); err != nil {
|
|
return "", &PluginError{Kind: PluginErrorUnavailable, Message: "store install token", Err: err}
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
// InstallCredentials is returned exactly once when an admin rotates a Plugin's
|
|
// standing credential. SigningSecret is empty when hook signing is disabled;
|
|
// the install token remains usable for the Public API in that deployment.
|
|
type InstallCredentials struct {
|
|
Token string
|
|
SigningSecret string
|
|
}
|
|
|
|
// RotateInstallCredentials prepares every response value before replacing the
|
|
// stored token hash. This keeps an optional hook-signing configuration failure
|
|
// from invalidating the previous token after the response has already become
|
|
// impossible to complete.
|
|
func (s *PluginService) RotateInstallCredentials(ctx context.Context, installationID pgtype.UUID) (InstallCredentials, error) {
|
|
var signingSecret string
|
|
if len(s.DeploymentKey) > 0 {
|
|
secret, err := s.HookSigningSecret(installationID)
|
|
if err != nil {
|
|
return InstallCredentials{}, err
|
|
}
|
|
signingSecret = secret
|
|
}
|
|
token, err := s.IssueInstallToken(ctx, installationID)
|
|
if err != nil {
|
|
return InstallCredentials{}, err
|
|
}
|
|
return InstallCredentials{Token: token, SigningSecret: signingSecret}, nil
|
|
}
|
|
|
|
// RevokeInstallToken drops the stored hash, so nothing presented afterwards
|
|
// matches. Rotation is IssueInstallToken, which overwrites in place.
|
|
func (s *PluginService) RevokeInstallToken(ctx context.Context, installationID pgtype.UUID) error {
|
|
if err := s.Queries.SetPluginInstallationToken(ctx, db.SetPluginInstallationTokenParams{
|
|
ID: installationID,
|
|
TokenHash: pgtype.Text{},
|
|
}); err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "revoke install token", Err: err}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AuthenticateInstallToken resolves a presented token to its installation.
|
|
func (s *PluginService) AuthenticateInstallToken(ctx context.Context, token string) (db.PluginInstallation, error) {
|
|
token = strings.TrimSpace(token)
|
|
if !strings.HasPrefix(token, installTokenPrefix) {
|
|
return db.PluginInstallation{}, pluginErrf(PluginErrorForbidden, "invalid plugin token")
|
|
}
|
|
installation, err := s.Queries.GetPluginInstallationByTokenHash(ctx, pgtype.Text{String: hashToken(token), Valid: true})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return db.PluginInstallation{}, pluginErrf(PluginErrorForbidden, "invalid plugin token")
|
|
}
|
|
if err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "load plugin installation", Err: err}
|
|
}
|
|
if !installation.Enabled {
|
|
return db.PluginInstallation{}, pluginErrf(PluginErrorForbidden, "this Plugin is disabled")
|
|
}
|
|
return installation, nil
|
|
}
|
|
|
|
func hashToken(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// CallbackGrant is what a redeemed callback token proves.
|
|
//
|
|
// Its scopes are the installation's, never wider: the callback exists so a hook
|
|
// handler can finish the job it was called for, not so an out-of-band request
|
|
// can do more than the surface could.
|
|
type CallbackGrant struct {
|
|
InstallationID pgtype.UUID
|
|
WorkspaceID pgtype.UUID
|
|
HookKey string
|
|
Trigger string
|
|
// Actor is who the resulting writes belong to, decided when the hook was
|
|
// dispatched. A handler cannot choose to write as somebody else.
|
|
Actor HookActor
|
|
// IssueID narrows an event callback to the issue that produced it. Zero
|
|
// when the invocation had no issue.
|
|
IssueID pgtype.UUID
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// CallbackTokens issues and resolves the per-invocation callback tokens.
|
|
//
|
|
// Scoped to one INVOCATION, not one request — and that distinction was found by
|
|
// running a real handler rather than by reasoning about it. A single-use token
|
|
// looked stricter and was: the reference handler reads the issue, decides, then
|
|
// posts a comment, and the second call died on an already-spent token. Two calls
|
|
// is the floor for any handler that does something with what it read.
|
|
//
|
|
// The trade that actually matters is not one-call versus several. It is this
|
|
// token versus the installation's standing token: a handler that cannot finish
|
|
// its job with the callback will simply be written against the install token
|
|
// instead, which never expires and is not scoped to an invocation. A control
|
|
// that pushes authors toward the stronger credential is worse than the slightly
|
|
// looser one they will use.
|
|
//
|
|
// What still bounds it: minutes, this installation's granted scopes, the actor
|
|
// decided at dispatch, and the issue the invocation was about.
|
|
//
|
|
// Held in memory. Across several instances or after a restart a token stops
|
|
// resolving early rather than late, so the failure mode is a handler seeing 403
|
|
// — visible and retriable — not a grant outliving its window.
|
|
type CallbackTokens struct {
|
|
mu sync.Mutex
|
|
issued map[string]CallbackGrant
|
|
}
|
|
|
|
func NewCallbackTokens() *CallbackTokens {
|
|
return &CallbackTokens{issued: make(map[string]CallbackGrant)}
|
|
}
|
|
|
|
// Issue mints a token for one hook invocation.
|
|
func (c *CallbackTokens) Issue(ctx context.Context, invocation HookInvocation) (string, error) {
|
|
raw := make([]byte, callbackTokenEntropy)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return "", &PluginError{Kind: PluginErrorUnavailable, Message: "generate callback token", Err: err}
|
|
}
|
|
token := callbackTokenPrefix + base64.RawURLEncoding.EncodeToString(raw)
|
|
|
|
grant := CallbackGrant{
|
|
InstallationID: invocation.Installation.ID,
|
|
WorkspaceID: invocation.Installation.WorkspaceID,
|
|
HookKey: invocation.Hook.Key,
|
|
Trigger: invocation.Trigger,
|
|
Actor: invocation.Actor,
|
|
IssueID: invocation.IssueID,
|
|
ExpiresAt: time.Now().Add(callbackTokenTTL),
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.sweepLocked()
|
|
c.issued[hashToken(token)] = grant
|
|
_ = ctx
|
|
return token, nil
|
|
}
|
|
|
|
// Resolve looks a token up. Valid for as many calls as the handler needs until
|
|
// it expires; see the type comment for why that is the right bound.
|
|
func (c *CallbackTokens) Resolve(token string) (CallbackGrant, error) {
|
|
token = strings.TrimSpace(token)
|
|
if !strings.HasPrefix(token, callbackTokenPrefix) {
|
|
return CallbackGrant{}, pluginErrf(PluginErrorForbidden, "invalid callback token")
|
|
}
|
|
key := hashToken(token)
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.sweepLocked()
|
|
grant, ok := c.issued[key]
|
|
if !ok {
|
|
return CallbackGrant{}, pluginErrf(PluginErrorForbidden, "callback token is expired or unknown")
|
|
}
|
|
if time.Now().After(grant.ExpiresAt) {
|
|
delete(c.issued, key)
|
|
return CallbackGrant{}, pluginErrf(PluginErrorForbidden, "callback token is expired or unknown")
|
|
}
|
|
return grant, nil
|
|
}
|
|
|
|
// Revoke drops a grant before its expiry. Called when a hook invocation has
|
|
// finished, so a token stops working the moment the work it was issued for is
|
|
// over rather than lingering for the rest of its window.
|
|
func (c *CallbackTokens) Revoke(token string) {
|
|
if token == "" {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
delete(c.issued, hashToken(strings.TrimSpace(token)))
|
|
}
|
|
|
|
// sweepLocked drops expired grants so the map cannot grow without bound on a
|
|
// long-running server. Callers hold the mutex.
|
|
func (c *CallbackTokens) sweepLocked() {
|
|
now := time.Now()
|
|
for key, grant := range c.issued {
|
|
if now.After(grant.ExpiresAt) {
|
|
delete(c.issued, key)
|
|
}
|
|
}
|
|
}
|