Files
PrivateCaptcha/pkg/api/server_enterprise.go
Copilot e09db1dbb8 Allow org members without subscription to create properties via API (#251)
* Initial plan

* Allow org members without subscription to create properties via API

- Add requestUserEx() to allow skipping subscription check
- Update postNewProperties() to not require user subscription (org owner subscription is checked instead)
- Update getAsyncTask() to not require user subscription
- Add integration tests for Portal and API to verify org members can create properties

Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com>

* Fix TestApiPostPropertiesNoSubscription to check API error code

Update test to use requestResponseAPISuite and check for StatusSubscriptionPropertyLimitError instead of HTTP 402

Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com>

* Refactor tests and fix task handler subscription check

- Fix getAsyncTask to require subscription (nil subscriptions blocked, expired trials allowed)
- Refactor API tests with helper functions createPropertyInputs, waitForAsyncTaskCompletion
- Refactor API and Portal tests as test suites for nil and expired trial subscriptions
- Add step to test non-member rejection before join in API tests
- Simplify Portal tests to focus on member property creation

Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com>

* Fix redundant imports in property_test.go

Use consistent db_test alias instead of multiple aliases for the same package

Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com>

* Add non-member rejection test and refactor async task wait helpers

- Add non-member rejection step in API tests (verifies 403 Forbidden before invite)
- Simplify Portal tests (just verify member can create properties after joining)
- Refactor multiple async task wait loops to use waitForAsyncTaskCompletion helper
- Add waitForAsyncTaskCompletionWithResult helper for tests that need results

Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com>

* Ensure invited-but-not-joined members cannot create properties

- Add RetrieveUserOrganizationMember() that excludes invited users
- Add requestOrgMember() API helper that uses the new function
- Add OrgMember() Portal helper that uses the new function
- Update postNewProperties() to use requestOrgMember()
- Update postNewOrgProperty() to use OrgMember()
- Add test steps for invited-but-not-joined rejection in API and Portal tests

Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com>

* Refactor: return access level from requestOrg/Org instead of new functions

- Modify RetrieveUserOrganization to return access level as third value
- Delete RetrieveUserOrganizationMember function
- Modify requestOrg to return access level as third value
- Delete requestOrgMember function
- Modify Org to return access level as third value
- Delete OrgMember function
- Update all callers to ignore unused access level return values
- Check access level in postNewProperties and postNewOrgProperty to block invited users

Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com>

* Fix code review: use 403 Forbidden and check level.Valid

- Use HTTP 403 Forbidden instead of 500 in Portal for invited users
- Add level.Valid check before accessing level.AccessLevel

Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com>
2026-01-15 12:06:34 +02:00

189 lines
7.6 KiB
Go

//go:build enterprise
package api
import (
"context"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/common"
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/db"
dbgen "github.com/PrivateCaptcha/PrivateCaptcha/pkg/db/generated"
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/monitoring"
"github.com/jackc/pgx/v5/pgtype"
"github.com/justinas/alice"
)
const (
maxAPIPostBodySize = 128 * 1024
maxPostPropertiesBodySize = 1024 * 1024
maxDeletePropertiesBodySize = 128 * 1024
maxUpdatePropertiesBodySize = 1024 * 1024
)
func (s *Server) setupEnterprise(rg *common.RouteGenerator, publicChain alice.Chain, apiRateLimiter func(next http.Handler) http.Handler) {
arg := func(s string) string {
return fmt.Sprintf("{%s}", s)
}
// "portal" API
portalAPIChain := publicChain.Append(s.Metrics.HandlerIDFunc(rg.LastPath), apiRateLimiter, monitoring.Traced, common.SoftTimeoutHandler(5*time.Second), s.Auth.APIKey(headerAPIKey, dbgen.ApiKeyScopePortal))
// tasks
rg.Handle(rg.Get(common.AsyncTaskEndpoint, arg(common.ParamID)), portalAPIChain, http.HandlerFunc(s.getAsyncTask))
// orgs
rg.Handle(rg.Get(common.OrganizationsEndpoint), portalAPIChain, http.HandlerFunc(s.getUserOrgs))
rg.Handle(rg.Post(common.OrgEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.postNewOrg), maxAPIPostBodySize))
rg.Handle(rg.Put(common.OrgEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.updateOrg), maxAPIPostBodySize))
rg.Handle(rg.Delete(common.OrgEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.deleteOrg), maxAPIPostBodySize))
// properties
rg.Handle(rg.Get(common.OrgEndpoint, arg(common.ParamOrg), common.PropertiesEndpoint), portalAPIChain, http.HandlerFunc(s.getOrgProperties))
rg.Handle(rg.Post(common.OrgEndpoint, arg(common.ParamOrg), common.PropertiesEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.postNewProperties), maxPostPropertiesBodySize))
rg.Handle(rg.Delete(common.PropertiesEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.deleteProperties), maxDeletePropertiesBodySize))
rg.Handle(rg.Put(common.PropertiesEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.updateProperties), maxUpdatePropertiesBodySize))
rg.Handle(rg.Get(common.OrgEndpoint, arg(common.ParamOrg), common.PropertyEndpoint, arg(common.ParamProperty)), portalAPIChain, http.HandlerFunc(s.getOrgProperty))
}
func (s *Server) RegisterTaskHandlers(ctx context.Context) {
if ok := s.AsyncTasks.Register(createPropertiesHandlerID, s.handleCreateProperties); !ok {
slog.ErrorContext(ctx, "Failed to register async task handler", "handler", createPropertiesHandlerID)
}
if ok := s.AsyncTasks.Register(deletePropertiesHandlerID, s.handleDeleteProperties); !ok {
slog.ErrorContext(ctx, "Failed to register async task handler", "handler", deletePropertiesHandlerID)
}
if ok := s.AsyncTasks.Register(updatePropertiesHandlerID, s.handleUpdateProperties); !ok {
slog.ErrorContext(ctx, "Failed to register async task handler", "handler", updatePropertiesHandlerID)
}
}
func (s *Server) requestUser(ctx context.Context, readOnly bool) (*dbgen.User, *dbgen.APIKey, error) {
return s.requestUserEx(ctx, readOnly, true /*requiresSubscription*/)
}
func (s *Server) requestUserEx(ctx context.Context, readOnly bool, requiresSubscription bool) (*dbgen.User, *dbgen.APIKey, error) {
portalOwnerSource := &apiKeyOwnerSource{Store: s.BusinessDB, scope: dbgen.ApiKeyScopePortal}
id, _, err := portalOwnerSource.OwnerID(ctx, time.Now().UTC())
if err != nil {
return nil, nil, err
}
if portalOwnerSource.cachedKey != nil && (portalOwnerSource.cachedKey.Readonly && !readOnly) {
slog.WarnContext(ctx, "API key read-write attribute does not match", "expected", readOnly,
"actual", portalOwnerSource.cachedKey.Readonly)
return nil, nil, errAPIKeyReadOnly
}
user, err := s.BusinessDB.Impl().RetrieveUser(ctx, id)
if err != nil {
return nil, nil, err
}
if requiresSubscription && !user.SubscriptionID.Valid {
return nil, nil, db.ErrNoActiveSubscription
}
return user, portalOwnerSource.cachedKey, nil
}
func (s *Server) requestOrg(user *dbgen.User, r *http.Request, onlyOwner bool, allowedOrgID *pgtype.Int4) (*dbgen.Organization, dbgen.NullAccessLevel, error) {
ctx := r.Context()
orgID, value, err := common.IntPathArg(r, common.ParamOrg, s.IDHasher)
if err != nil {
slog.ErrorContext(ctx, "Failed to parse org path parameter", "value", value, common.ErrAttr(err))
return nil, dbgen.NullAccessLevel{}, db.ErrInvalidInput
}
if (allowedOrgID != nil) && allowedOrgID.Valid && (allowedOrgID.Int32 != orgID) {
slog.WarnContext(ctx, "Requested organization is not allowed for this requester", "allowedOrgID", allowedOrgID.Int32, "requestedOrgID", orgID)
return nil, dbgen.NullAccessLevel{}, db.ErrPermissions
}
org, level, err := s.BusinessDB.Impl().RetrieveUserOrganization(ctx, user, orgID)
if err != nil {
return nil, dbgen.NullAccessLevel{}, err
}
if onlyOwner {
if !org.UserID.Valid || (org.UserID.Int32 != user.ID) {
return nil, dbgen.NullAccessLevel{}, db.ErrPermissions
}
}
return org, level, nil
}
func (s *Server) requestProperty(org *dbgen.Organization, r *http.Request) (*dbgen.Property, error) {
ctx := r.Context()
propertyID, value, err := common.IntPathArg(r, common.ParamProperty, s.IDHasher)
if err != nil {
slog.ErrorContext(ctx, "Failed to parse property path parameter", "value", value, common.ErrAttr(err))
return nil, db.ErrInvalidInput
}
property, err := s.BusinessDB.Impl().RetrieveOrgProperty(ctx, org, propertyID)
if err != nil {
return nil, err
}
return property, nil
}
func (s *Server) sendHTTPErrorResponse(err error, w http.ResponseWriter) {
switch err {
case db.ErrRecordNotFound:
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
case db.ErrInvalidInput:
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
case db.ErrNoActiveSubscription:
http.Error(w, http.StatusText(http.StatusPaymentRequired), http.StatusPaymentRequired)
case db.ErrMaintenance:
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
case errAPIKeyScope, errInvalidAPIKey, errAPIKeyNotSet, errAPIKeyReadOnly, db.ErrPermissions:
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
case db.ErrSoftDeleted:
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
default:
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
func (s *Server) sendAPISuccessResponse(ctx context.Context, data interface{}, w http.ResponseWriter) {
s.sendAPISuccessResponseEx(ctx, &APIResponse{Data: data}, w, common.NoCacheHeaders)
}
func (s *Server) sendAPISuccessResponseEx(ctx context.Context, response *APIResponse, w http.ResponseWriter, headers ...map[string][]string) {
response.Meta = ResponseMetadata{
Code: common.StatusOK,
Description: common.StatusOK.String(),
}
if tid, ok := ctx.Value(common.TraceIDContextKey).(string); ok {
response.Meta.RequestID = tid
}
common.SendJSONResponse(ctx, w, response, headers...)
}
func (s *Server) sendAPIErrorResponse(ctx context.Context, code common.StatusCode, r *http.Request, w http.ResponseWriter) {
response := &APIResponse{
Meta: ResponseMetadata{
Code: code,
Description: code.String(),
},
}
if tid, ok := ctx.Value(common.TraceIDContextKey).(string); ok {
response.Meta.RequestID = tid
}
common.SendJSONResponse(ctx, w, response, common.NoCacheHeaders)
slog.WarnContext(ctx, "Returned API error response", "code", int(code))
s.Metrics.ObserveApiError(r.URL.Path, r.Method, int(code))
}