Arhitektura

Temelji, modul za modulom

Koda toolkita in gradnikov ni javna; ta stran je njen javni opis, napisan na podlagi kode. Za vsak del pove, česa produktu ni več treba graditi, kako deluje, kaj jamči in s katerimi deli se povezuje. Pokaže tudi nekaj vrstic, ki jih produkt napiše, da ga uporabi.

Stanje: oktober 2026

Kako se deli povezujejo

Sledimo eni zahtevi na končno točko, ki deluje v okviru organizacije. Zahteva nosi žeton, ki ga je uporabnik ob prijavi dobil od Keycloaka iz gradnika za identiteto. Modul Tenancy ugotovi, za katero organizacijo gre. Preverjanje članstva iz modula Onboarding potrdi, da je uporabnik njen član, nadzor življenjskega cikla iz modula Organizations pa zavrne zahtevo, če je organizacija začasno onemogočena ali izbrisana. Končna točka nato vpraša modula Entitlements in Usage, ali organizacija sme opraviti ta klic in ali ga njena kvota še dopušča. Modul Billing deluje ob strani: iz webhookov plačilnega ponudnika posodablja naročniški paket organizacije, na katerem temeljijo ti odgovori.

Operater do istih organizacij dostopa prek skrbniškega API-ja. Vse skupaj obdajata gradnik za oblikovni sistem, jedro uporabniškega vmesnika produkta, in gradnik za CI/CD, ki produkt pripelje od testov do namestitve s preverjanjem zdravja.

Pot ene zahteve skozi dele, na katerih stoji produkt Stolpec delov, skozi katere gre zahteva: od gradnika za identiteto prek modulov Tenancy in Organizations do modulov Entitlements in Usage. Pod njimi je modul Billing, ki modulu Entitlements posreduje naročniški paket od plačilnega ponudnika. Ob stolpcu je skrbniški API, prek katerega operater dostopa do istih organizacij. Nad stolpcem je gradnik za oblikovni sistem, uporabniški vmesnik produkta, kjer se zahteva začne, celoto pa obdaja gradnik za CI/CD. Gradnik za CI/CD testi, gradnja slik, namestitev Gradnik za oblikovni sistem uporabniški vmesnik produkta zahteva Gradnik za identiteto prijava, pogoji uporabe Tenancy katera organizacija Organizations uvajanje, člani, vloge Entitlements in Usage kaj dovoli naročniški paket naročniški paket Billing od plačilnega ponudnika Skrbniški API za operaterja

Gradnik · vsebniška slika

Gradnik za identiteto

Keycloak produkta stoji na eni pripeti sliki in ga pripravijo njeni skripti, noben uporabnik pa ne dobi žetona, preden sprejme pogoje uporabe.

Kako deluje

Gradnik je osnovna slika, ne storitev, ki teče: Keycloak, pripet na povzetek (digest), dva skripta in slovenska sporočila za prijavo. Produkt doda svojo temo za prijavo, ki jo zgradi v prejšnji fazi svojega Dockerfila, in požene kc.sh build. Privzetih vrednosti produkta gradnik ne vsebuje: kratek ovojni skript v produktu jih hrani in v vsebniku požene setup.

setup pripravi realm. To je skript in ne izvoz realma, saj je izvoz strojno ustvarjen JSON, ki ga nihče ne pregleda. Skript korak za korakom zapiše odločitve, od katerih je odvisen API: dva javna odjemalca s PKCE (S256), enega za aplikacijo produkta in enega za operaterjevo; občinstvo (audience), ki ga API preverja; vloge realma, zbrane v zahtevku roles za ASP.NET; deklarirani atribut organization_id in operaterjevo vlogo realma.

require-terms sprejem pogojev uporabe nastavi kot privzeto zahtevano dejanje in ga dodeli obstoječim uporabnikom, ki pogojev še niso sprejeli. Oba skripta je varno pognati znova, setup pa ustvari le, kar manjka, zato ju lahko namestitev požene vsakič.

Kaj jamči

  • Pripeto vse do orodij za gradnjo. Keycloak je pripet na povzetek, prav tako orodja, ki sliko zgradijo: frontend za Dockerfile in BuildKit. Program jq, ki ga slika vsebuje, je pripet na kontrolno vsoto. Slika nima koraka RUN, testi pa jo zgradijo z istim BuildKitom, s katerim jo zgradi izdaja.
  • Ustavi se, preden kar koli spremeni. Če manjka obvezna spremenljivka, se setup ustavi, še preden se prijavi v Keycloak, in navede vse manjkajoče. Test po vrsti izpusti vsako od devetih obveznih spremenljivk in preveri, da noben zavrnjen zagon ni spremenil Keycloaka.
  • Brez žetona pred pogoji. Nov uporabnik dobi zahtevano dejanje. V testih gradnika Keycloak pred sprejemom zavrne prijavo z geslom (password grant), po sprejemu pa izda žeton. Zavrnitev pogojev prijavo prekine. Shrani se le čas sprejema, Keycloak pa prezre zapis tega časa prek svojega vmesnika Admin REST API.
  • Kar pripravi setup, se ne spremeni neopazno. Realm, pripravljen iz danega nabora spremenljivk, je po normalizaciji shranjen v repozitoriju kot referenčna datoteka (golden file), test pa na novo pripravljeni realm z njo primerja bajt za bajtom. Zato sprememba tega realma, tudi tista, ki jo prinese nadgradnja Keycloaka, v pregled pride kot razlika (diff).

Kaj napiše produkt

Celotna Keycloakova slika produkta, po fazi, ki zgradi njegovo temo:

# The kit's image; kc.sh build runs here because it must follow the theme JAR.
FROM <registry>/<toolkit>/keycloak:X.Y.Z@sha256:…
COPY --from=theme /app/keycloak-theme/dist_keycloak/<product>-keycloak-theme.jar \
     /opt/keycloak/providers/
RUN /opt/keycloak/bin/kc.sh build --db=postgres --health-enabled=true

Ovojni skript, ki setup požene z vrednostmi produkta:

CONTAINER="${KEYCLOAK_CONTAINER:-<product>-keycloak}"
export KEYCLOAK_REALM="${KEYCLOAK_REALM:-<realm>}"
export KEYCLOAK_CLIENT_ID="${KEYCLOAK_CLIENT_ID:-<realm>-spa}"
# ... the other required names, each with the product's default
flags=()
for name in KEYCLOAK_REALM KEYCLOAK_CLIENT_ID KEYCLOAK_API_AUDIENCE ADMIN_CLIENT_ID \
            SPA_ORIGINS ADMIN_ORIGINS DEV_USER DEV_PASSWORD ORGANIZATION_ID; do
  flags+=(-e "$name")
done
exec docker exec "${flags[@]}" "$CONTAINER" /opt/microsaas/keycloak/setup

Povezave

Žetoni nosijo, kar bere API: večnajemniško delovanje in uvajanje uporabnika prepoznata po subjektu žetona, operaterjeva vloga realma pa je sistemski obseg (system scope), ki ga zahteva skrbniški API. Po namestitvi lahko preverjanje zdravja v gradniku za CI/CD potrdi, da stran za prijavo prihaja iz teme produkta.

setuprequire-terms

Paketi .NET

Večnajemniška zasnova

Produkt organizacije ne ugotavlja in ne preverja sam ter ne piše filtra po organizaciji: končna točka označi, da organizacijo potrebuje, opravilo v ozadju zanjo odpre obseg, poizvedbe po entitetah produkta pa vidijo le vrstice te organizacije.

Kako deluje

Končna točka v metapodatkih pove, kaj potrebuje: [RequiresTenant] za klic znotraj ene organizacije, [AllowInactiveTenant] za klic, ki mora odgovoriti tudi takrat, ko je organizacija zaklenjena, [AllowSystemScope(…)] za operaterjev klic, ki ga dovoli le vloga ali scope v žetonu, ki ju atribut navede, in [AllowAnonymousTenant] za klic zunaj večnajemniškega delovanja.

Pri končni točki za najemnika vmesna programska oprema (middleware) organizacijo prebere iz glave X-Organization-Id. Vrednost, ki ni GUID, zavrne; brez glave prebere zahtevek iz žetona le, če produkt to možnost vklopi. Ko je registriran modul Onboarding, middleware nato v podatkovni bazi preveri, ali je klicatelj član organizacije. Življenjski cikel organizacije prebere ob vsaki taki zahtevi, zato začasna onemogočitev velja že za naslednji klic. Organizacija je potem do konca zahteve shranjena v okoliškem kontekstu (ambient context).

Podatkovna plast bere isti kontekst. Entiteta produkta implementira IOrganizationEntity, DbContext produkta pa na vsako takšno entiteto postavi poizvedbeni filter toolkita, ki vrača le vrstice trenutne organizacije (primer je v razdelku Uvajanje in organizacije). Prestreznik shranjevanja, priključen skupaj z DbContextom, v novo vrstico vpiše organizacijo. Opravilo v ozadju kontekst nastavi samo: ITenantScopeFactory odpre obseg za eno organizacijo, za nobeno ali sistemski obseg (system scope), ki mu je treba navesti razlog, ob zaprtju obsega pa vrne prejšnji kontekst.

Kaj jamči

  • Zavrnitve nosijo kodo. Lastne zavrnitve middlewara so problemi po RFC 9457 s strojno kodo: tenant_required, tenant_invalid, not_authenticated, tenant_access_denied, tenant_inactive ali system_scope_not_permitted. Testi toolkita preberejo in preverijo telo odgovora.
  • Zaklenjena organizacija ne pride skozi. Začasno onemogočena organizacija, organizacija v čakanju na izbris in izbrisana organizacija dobijo odgovor 423 na vsaki končni točki za najemnika, ki neaktivnih organizacij ne dopušča.
  • Brez najemnika ni vrstic. Brez organizacije v kontekstu poizvedba po IOrganizationEntity ne vrne vseh vrstic, temveč nobene; vse organizacije vidi le sistemski obseg.
  • Vrstica ostane v svoji organizaciji. Če se vrstici znotraj najemnika spremeni organizacija, prestreznik shranjevanje zavrne z izjemo. Testi v kanarčku tovarne, produktu, ki izdaje gradnikov prevzame prvi, to dokažejo na pravi podatkovni bazi PostgreSQL, skupaj s filtrom in z vpisom organizacije ob vstavljanju. Ločenost organizacij dokažejo še enkrat prek celotne verige obdelave v API-ju.
  • Kontekst se konča z zahtevo. Middleware ob koncu zahteve kontekst počisti, ne glede na to, ali je uspela ali ne.

Kaj napiše produkt

Povezava modulov in krmilnik (controller) brez kode za najemnike:

builder.Services.AddMicroSaasTenancy();           // the X-Organization-Id header, scopes
// Onboarding brings the membership check, Organizations the lifecycle gate.
builder.Services.AddMicroSaasOnboarding<AppDbContext>(o => o.ProductCode = "myproduct");
builder.Services.AddMicroSaasOrganizations<AppDbContext>();
builder.Services.AddDbContext<AppDbContext>((sp, o) => o.UseNpgsql(connectionString)
    .AddInterceptors(sp.GetRequiredService<OrganizationSaveChangesInterceptor>()));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseMicroSaasTenancy();                        // needs the user: after authentication
[ApiController, Route("api/projects"), RequiresTenant, Authorize]
public sealed class ProjectsController(AppDbContext db) : ControllerBase
{
    [HttpGet]   // no Where: the tenant filter returns this organization's rows only
    public Task<List<Project>> List(CancellationToken ct) => db.Projects.ToListAsync(ct);
    [HttpPost]  // no OrganizationId: the save interceptor sets it from the tenant
    public async Task Create(NewProject request, CancellationToken ct)
    {
        db.Projects.Add(new Project { Name = request.Name });
        await db.SaveChangesAsync(ct);
    }
}

Kaj dobi odjemalec začasno onemogočene organizacije:

HTTP/1.1 423 Locked
Content-Type: application/problem+json

{"title":"Locked","status":423,"detail":"Tenant is not active.","code":"tenant_inactive"}

Povezave

Modula Onboarding in Organizations prineseta preverjanje članstva, življenjski cikel in OrganizationDbContextBase, osnovo produktovega DbContexta. Skrbniški API deluje v sistemskem obsegu. Modul Entitlements bere isti življenjski cikel in zaklenjeni organizaciji vrne prazen posnetek, kvote modula Usage pa zavračajo v enaki obliki problema.

Tenancy.AbstractionsTenancy.Runtime

Paketi .NET

Uvajanje in organizacije

Vse od prve prijave uporabnika do ekipe, ki dela v organizaciji: prva organizacija in njen uporabnik z vlogo Owner, povabila, vloge, življenjski cikel ter revizijski zapis ob vsaki spremembi članstva.

Kako deluje

Ob prvi prijavi aplikacija pokliče eno končno točko, POST /api/onboarding/ensure. Uporabnik, ki že pripada organizaciji, dobi nazaj obstoječo. Ko je treba organizacijo ustvariti, ena transakcija shrani uporabnika ter ustvari organizacijo z enoličnim imenom za URL (slug), članstvo z vlogo Owner, stanje uvajanja in revizijski zapis. Kljuke (hooks) produkta, IOrganizationCreatedHook, se izvedejo v tej transakciji, preden se ta zaključi; modul Entitlements prek ene od njih novi organizaciji dodeli privzete pravice do funkcij.

Povabila, člani in življenjski cikel so končne točke modula Organizations. Uporabnik z vlogo Admin ali Owner ustvari povabilo za e-poštni naslov in vlogo; povabljeni ga, ko je prijavljen, sprejme z žetonom povabila, s čimer nastane članstvo. Seznam članov lahko vidi vsak član. Organizacija začne kot Active in lahko postane Suspended, PendingDeletion ali Deleted. Njeno stanje je berljivo tudi, ko je zaklenjena; operater jo začasno onemogoči in znova omogoči v sistemskem obsegu (system scope), večnajemniško delovanje pa zaklenjeni organizaciji odgovori z 423.

Kaj jamči

  • Ponovni klic ne ustvari druge organizacije. Onboarding obstoječe članstvo poišče pred transakcijo in še enkrat v njej. Test pokliče Onboarding za uporabnika, ki že pripada organizaciji, izid pa je AlreadyProvisioned s to organizacijo.
  • Shrani se le zgoščena vrednost žetona povabila. Žeton je 32 naključnih bajtov; podatkovna baza hrani le njegovo zgoščeno vrednost SHA-256 s poprom (pepper), ki ga nastavi produkt, v stolpcu z enoličnim indeksom; brez popra povabila ni mogoče ne ustvariti ne sprejeti. Novo povabilo prekliče prejšnja veljavna povabila organizacije na isti naslov, sprejetega povabila pa ni mogoče uporabiti znova. Neznan, preklican, že uporabljen ali potekel žeton dobi vsak svojo kodo napake.
  • Zadnji Owner ostane. Le Owner lahko spremeni vlogo uporabnika z vlogo Owner ali ga odstrani, zadnjemu uporabniku z vlogo Owner pa vloge ni mogoče znižati in ga ni mogoče odstraniti (LAST_OWNER_GUARD). Revizijski dnevnik dobi vrstico ob vsakem ustvarjanju, preklicu in sprejemu povabila, ob vsaki spremembi vloge in ob vsaki odstranitvi člana.

Kaj napiše produkt

DbContext produkta na osnovnem razredu toolkita, z modeli toolkita in s filtrom po najemniku, dodanim na koncu:

public sealed class AppDbContext(
    DbContextOptions<AppDbContext> options, ITenantContextAccessor tenant)
    : OrganizationDbContextBase(options, tenant)
{
    public DbSet<Project> Projects => Set<Project>();   // Project : IOrganizationEntity
    protected override void OnModelCreating(ModelBuilder b)
    {
        b.AddMicroSaasOnboardingModels();    // users, organizations, members, audit log
        b.AddMicroSaasOrganizationModels();  // invites, lifecycle
        b.AddOrganizationQueryFilters(this); // last: filters every IOrganizationEntity
    }
}

Povabila s kvoto članov in končne točke. Kljuka se izvede po preverjanju vloge in preden žeton sploh obstaja: število članov priskrbi produkt, modul Usage ga primerja s kvoto organizacije, zavrnitev pa odjemalec dobi kot problem 403 s kodo QUOTA_*.

builder.Services.AddMicroSaasOrganizations<AppDbContext>(o => o.PreCreateInviteHook =
    (sp, organizationId, ct) => sp.RequireStatefulQuotaAsync(
        "seats.max", additional: 1,              // a Quota entitlement of the plan
        getCurrentUsage: (org, c) => sp.GetRequiredService<AppDbContext>()
            .Set<MsMembership>().CountAsync(m => m.OrganizationId == org, c),
        ct));
// ...
app.MapMicroSaasOnboardingEndpoints<AppDbContext>();     // first login, me, organizations
app.MapMicroSaasOrganizationEndpoints<AppDbContext>();   // invites, members, lifecycle

Povezave

Večnajemniško delovanje se zanaša na preverjanje članstva iz modula Onboarding in na življenjski cikel iz modula Organizations. Kljuka za kvoto članov pokliče modul Usage, ki kvoto prebere iz modula Entitlements, skrbniški API pa dela z istimi organizacijami, člani, življenjskim ciklom in revizijskim dnevnikom.

Onboarding.ApiOnboarding.EFCoreOrganizations.ApiOrganizations.EFCore

Paketi .NET

Pravice do funkcij

Koda funkcij vpraša, ali sme organizacija uporabiti funkcijo in koliko česa sme imeti, odgovor pa dobi iz dodelitev organizacije, ne iz imena naročniškega paketa.

Kako deluje

Pravica do funkcije je ključ z vrsto: stikalo (Boolean), število (Quota) ali vrednost JSON. Produkt svoje ključe in njihove vrste navede v registru, s katerim merilnik v modulu Usage preverja ključe. Dodelitve organizacije so vrstice: organizacija, po izbiri eden od njenih uporabnikov, ključ, vrsta, vrednost, vir ter neobvezen začetek in konec. Dodelitev šteje, dokler velja, torej če se je že začela, še ni potekla in ni bila preklicana. Preizkusno obdobje je zato dodelitev z rokom veljavnosti.

Razreševalnik veljavne dodelitve združi v posnetek za organizacijo ali za organizacijo in enega njenih uporabnikov, pri čemer najprej upošteva dodelitve organizacije. Posnetek hrani v predpomnilniku procesa za nastavljiv čas, privzeto 2 minuti. Koda funkcij ga bere prek IEntitlementResolver: s HasAsync prebere stikalo, z GetQuotaAsync število.

Nova organizacija prve dodelitve dobi od kljuk (hooks) v transakciji uvajanja. Kljuka modula zapiše privzete dodelitve iz konfiguracije, po izbiri s številom dni preizkusnega obdobja, produkt pa lahko doda svojo, kot je spodnja, ki novi organizaciji dodeli brezplačni naročniški paket.

Kaj jamči

  • Privzeto ni dodeljeno nič. Ključa brez veljavne dodelitve v posnetku ni: HasAsync odgovori false, obe vrsti preverjanja kvot v modulu Usage pa zahtevo zavrneta.
  • Združevanje le razširi. Kadar imata za isti ključ dodelitev tako organizacija kot uporabnik, pri stikalu zmaga true, pri kvoti pa najvišja vrednost. Uporabnikova dodelitev zato lahko poveča, kar ima organizacija, zmanjšati pa tega ne more.
  • Zaklenjena organizacija ima prazen posnetek. Razreševalnik stanje življenjskega cikla preveri, preden pogleda v predpomnilnik; to preverjanje je privzeto vklopljeno. Zato koda funkcij, ki razreševalnik vpraša za začasno onemogočeno organizacijo, organizacijo v čakanju na izbris ali izbrisano organizacijo, prazen posnetek dobi že ob naslednjem klicu, ne šele, ko vnos v predpomnilniku poteče. Klicatelj lahko preverjanje izpusti. Testi razreševalnika preverijo to, obe pravili združevanja in prednost uporabnikove višje kvote pred kvoto organizacije.
  • Ena dodelitev na ključ. Dva delna enolična indeksa dovolita za vsak ključ eno nepreklicano dodelitev za organizacijo in eno za vsakega od njenih uporabnikov.

Kaj napiše produkt

Registracija modula s ključi produkta, kljuka in model v DbContextu:

builder.Services.AddMicroSaasEntitlements<AppDbContext>(
    builder.Configuration,                        // optional default grants
    o => o.CacheTtl = TimeSpan.FromMinutes(10),   // 2 minutes by default
    registry => registry
        .Add("exports.enabled", MsEntitlementType.Boolean, "CSV export")
        .Add("exports.monthly", MsEntitlementType.Quota, "Exports a month")
        .Add("projects.max", MsEntitlementType.Quota, "Projects at a time")
        .Add("seats.max", MsEntitlementType.Quota, "Members at a time"));
builder.Services.AddScoped<IOrganizationCreatedHook, AssignFreePlan>();
// In AppDbContext.OnModelCreating, before AddOrganizationQueryFilters(this):
b.AddMicroSaasEntitlementModels();

Kljuka v katalogu naročniških paketov produkta, prikazanem v razdelku Obračunavanje, poišče dodelitve brezplačnega naročniškega paketa in jih zapiše:

public sealed class AssignFreePlan(IPlanCatalog plans, IEntitlementGrantService grants)
    : IOrganizationCreatedHook   // runs in onboarding's transaction, before it commits
{
    public async Task OnOrganizationCreatedAsync(
        Guid organizationId, Guid? createdByUserId, CancellationToken ct)
    {
        var free = plans.GetGrants("free", DateTime.UtcNow, periodEndUtc: null);
        foreach (var grant in free)
            await grants.UpsertAsync(organizationId, createdByUserId, grant, ct);
    }
}

Povezave

Modul Onboarding kljuke izvede v svoji transakciji, življenjski cikel, ki ga bere razreševalnik, pa je isti, zaradi katerega večnajemniško delovanje zaklenjeni organizaciji odgovori z 423. Modul Usage omejitve vzame iz posnetka, modul Billing pa dodelitve zapisuje le prek istega IEntitlementGrantService, ki ga uporablja kljuka.

Entitlements.ApiEntitlements.EFCore

Paketi .NET

Merjenje porabe

Produkt šteje, kaj organizacija porabi, in izve, ali je naslednje dejanje še znotraj kvote, tako za dogodke v obdobju kot za vire, ki jih organizacija ima.

Kako deluje

Modul Usage pozna dve vrsti kvot, obe pa omejitev vzameta iz istega vira: iz vrednosti Quota, ki jo ima za ključ posnetek pravic do funkcij organizacije. Obe zato sledita isti dodelitvi.

Merjena kvota šteje dogodke, na primer izvoze v mesecu. Merilnik vsak dogodek prišteje dnevnemu števcu po UTC, za organizacijo ali za enega od njenih uporabnikov, preverjanje pa števce sešteje v časovnem oknu: privzeto v tekočem mesecu po UTC ali, če je tako nastavljeno, v zadnjih 30 dneh. CheckAndConsumeAsync z omejitvijo primerja porabljeno, povečano za zahtevano količino, zahtevo pa prišteje le, če jo dovoli. Merjeni ključ mora produkt v registru pravic do funkcij navesti kot kvoto.

Kvota za vire velja za to, kar organizacija ima, na primer člane ali projekte. Tu se nič ne meri: trenutno število priskrbi produkt iz svojih vrstic, RequireStatefulQuotaAsync pa z omejitvijo primerja to število, povečano za to, kar doda zahteva. Produkt z njo preverja število članov v kljuki za povabila, prikazani v razdelku Uvajanje in organizacije.

Za stran z naročniškim paketom ima vsaka vrsta klic, ki sporoči omejitev in porabljeno, ne da bi kar koli zavrnil: GetStatusAsync in GetStatefulQuotaStatusAsync. Produkt merilnik in preverjanje registrira z AddMicroSaasUsage in AddMicroSaasUsageQuotaGate, števce pa v svoj DbContext doda z metodo AddMicroSaasUsage na ModelBuilder.

Kaj jamči

  • Brez dodelitve ni dovoljenja. Obe vrsti zavrneta ključ brez dodelitve in ničelno omejitev; merilnik zavrne tudi ključ, ki ga produkt ni navedel kot kvoto.
  • Vsaka zavrnitev pove, zakaj. Odločitev, ki jo vrne CheckAndConsumeAsync, navede razlog: entitlement_missing, entitlement_type_mismatch, quota_zero ali quota_exceeded, testi pa preverijo vsakega od njih. Kvota za vire zahtevo zavrne s problemom 403 in kodo QUOTA_ENTITLEMENT_MISSING, QUOTA_DISABLED ali QUOTA_EXCEEDED, ki ga večnajemniško delovanje zapiše enako kot svoje zavrnitve.
  • Zavrnjena zahteva se ne šteje. Števec se poveča šele, ko preverjanje zahtevo dovoli, in le, če klicatelj to želi.
  • Časovno okno je nedvoumno. Števci se vodijo po dnevih UTC, okno pa je ena sama nastavitev preverjanja; testi preverijo, kje se začne vsako od obeh oken.

Kaj napiše produkt

Kvota za vire v končni točki, ki ustvari projekt:

[HttpPost]
public async Task Create(NewProject request, CancellationToken ct)
{
    // The count is the product's; at the limit, a 403 problem with code QUOTA_EXCEEDED.
    await HttpContext.RequireStatefulQuotaAsync("projects.max", additional: 1,
        getCurrentUsage: (o, c) => db.Projects.CountAsync(p => p.OrganizationId == o, c),
        ct);
    db.Projects.Add(new Project { Name = request.Name });
    await db.SaveChangesAsync(ct);
}

Merjena kvota v storitvi, ki jo pokliče končna točka:

public sealed class Exports(IUsageQuotaGate quota)
{
    public async Task<bool> TryStartAsync(Guid organizationId, CancellationToken ct)
    {
        var decision = await quota.CheckAndConsumeAsync(organizationId, userId: null,
            "exports.monthly", amount: 1, consumeIfAllowed: true, nowUtc: null, ct);
        // Counted only when allowed; a refusal's DenyReason is entitlement_missing,
        // entitlement_type_mismatch, quota_zero or quota_exceeded.
        return decision.Allowed;
    }
}

Povezave

Omejitve prihajajo iz modula Entitlements, kamor svoje dodelitve zapisuje tudi modul Billing. Preverjanje števila članov iz razdelka Uvajanje in organizacije je kvota za vire, zavrnitve do odjemalca pridejo prek večnajemniškega delovanja, skrbniški API pa pokaže porabo organizacije v zadnjih 7 in 30 dneh.

Usage.AbstractionsUsage.RuntimeUsage.ApiUsage.EFCore

Paketi .NET

Obračunavanje

Naročniški paket organizacije sledi njeni naročnini pri plačilnem ponudniku, modul Billing pa dodelitve zapisuje le prek modula Entitlements, zato koda funkcij ne potrebuje ne ponudnika ne njegovih identifikatorjev cen.

Kako deluje

Vsak plačilni ponudnik ima svoj adapter, ki implementira skupni vmesnik IBillingProvider. Paketi modula se ne sklicujejo na noben adapter, Stripovo knjižnico pa uporablja le adapter za Stripe. Adapter za Stripe se registrira z AddMicroSaasStripeBilling, adapter za Lemon Squeezy pa z AddMicroSaasLemonSqueezyBilling.

Webhook prispe na POST /api/v1/webhooks/{provider}, adapter s tem imenom pa ga preveri in pretvori v dogodek, neodvisen od ponudnika, ki nosi naročnino, ponudnikov identifikator naročniškega paketa, podatek, ali naročnina daje dostop, in čas dogodka. Končna točka za vsako dostavo webhooka hrani en zapis. Že obdelana dostava dobi odgovor 200 in se ne upošteva znova; vsaka druga se upošteva in označi kot obdelana ali pa ohrani napako in dobi odgovor z napako, zato jo ponudnik pošlje znova.

Sinhronizacija preskoči dogodek naročnine, ki je starejši od zadnjega upoštevanega, zabeleži stanje naročnine in izbere ključ naročniškega paketa. Dodelitve zapisuje le prek storitve za dodelitve modula Entitlements, IEntitlementGrantService, zato jih vidijo preverjanja v modulih Entitlements in Usage. Logika obračunavanja v produktu je njegov katalog naročniških paketov: IPlanCatalog ponudnikov identifikator naročniškega paketa pretvori v ključ naročniškega paketa, ta ključ pa v njegove dodelitve.

Kaj jamči

Vsaka lastnost velja za oba adapterja: vsak iz podatkov svojega ponudnika izpelje, kar potrebuje skupna koda.

  • Preverjeno, preden se kar koli shrani. Adapter za Stripe glavo Stripe-Signature preveri s Stripovo knjižnico, adapter za Lemon Squeezy pa HMAC-SHA256 neobdelanega telesa zahteve v konstantnem času primerja z glavo X-Signature. Dogodek, ki preverjanja ne prestane, dobi odgovor 400, še preden se dostava zabeleži. Testi ponaredijo Stripov podpis in spremenijo že podpisano telo dogodka Lemon Squeezy, v obeh primerih pa je dogodek zavrnjen.
  • Ponovna dostava izida ne spremeni. Identifikator dostave je pri Stripu identifikator dogodka. Lemon Squeezy ga ne pošlje, zato ga adapter izpelje iz imena dogodka, objekta in njegovega polja updated_at, ista dostava, poslana znova, pa dobi isti identifikator. Če se isti dogodek upošteva še enkrat, je izid isti naročniški paket.
  • Zapozneli dogodek ne povrne prejšnjega naročniškega paketa. Čas dogodka je pri Stripu polje created dogodka, pri Lemon Squeezy pa polje updated_at objekta. Dogodek naročnine, ki je starejši od zadnjega upoštevanega, se preskoči.
  • Naročniški paket sledi stanju naročnine, ne ceni v podatkih dogodka. Pri Stripu plačani naročniški paket ohranijo stanja active, trialing in past_due, pri Lemon Squeezy pa on_trial, active, past_due in cancelled, dokler naročnina ne poteče; naročnina v katerem koli drugem stanju, ki ga adapter pozna, plačanega naročniškega paketa ne obdrži. Neuspelo plačilo samo po sebi naročniškega paketa ne spremeni.

Kaj napiše produkt

Registracija in model v DbContextu:

builder.Services.AddMicroSaasBilling<AppDbContext>();
builder.Services.AddMicroSaasStripeBilling(stripeOptions);    // the provider's adapter
builder.Services.AddSingleton<IPlanCatalog, PlanCatalog>();   // the product's plans
// ...
app.MapMicroSaasBillingEndpoints<AppDbContext>();
// In AppDbContext.OnModelCreating, before AddOrganizationQueryFilters(this):
b.AddMicroSaasBilling();

Katalog naročniških paketov z nadomestnim identifikatorjem cene:

public sealed class PlanCatalog : IPlanCatalog
{
    public string ResolvePlanKey(string provider, string? planExternalId) =>
        planExternalId == "price_pro" ? "pro" : "free";

    public IReadOnlyList<UpsertEntitlementGrantRequest> GetGrants(
        string planKey, DateTime nowUtc, DateTime? periodEndUtc, Guid? userId = null) =>
        [new("projects.max", MsEntitlementType.Quota, null, planKey == "pro" ? 25 : 1,
            null, "subscription", nowUtc, null, userId),
         new("seats.max", MsEntitlementType.Quota, null, planKey == "pro" ? 10 : 3,
            null, "subscription", nowUtc, null, userId)];
}

Povezave

Dodelitve modula Billing razrešuje modul Entitlements, berejo pa jih tudi kvote modula Usage. Kljuka za brezplačni naročniški paket iz razdelka Pravice do funkcij prve dodelitve nove organizacije vzame iz istega kataloga.

Billing.AbstractionsBilling.ApiBilling.EFCoreBilling.Providers.StripeBilling.Providers.LemonSqueezy

Paketi .NET

Skrbniški API

Operater najde katero koli organizacijo produkta, vidi njene člane, porabo in zgodovino ter z zabeleženim razlogom spremeni stanje njenega življenjskega cikla, brez dostopa do podatkovne baze in brez skrbniškega zaledja, ki bi ga moral zgraditi produkt sam.

Kako deluje

MapMicroSaasAdminEndpoints vse končne točke API-ja postavi v eno skupino, ta pa nosi atribut [AllowSystemScope("admin:endpoints")]. Večnajemniško delovanje klic dovoli le, če žeton nosi admin:endpoints kot scope ali kot vlogo, nato pa ga izvede v sistemskem obsegu (system scope), ki zajema vse organizacije. Gradnik za identiteto to vlogo za operaterja ustvari v realmu.

Imenik omogoča iskanje organizacij po imenu ali po imenu za URL (slug) in filtriranje po stanju življenjskega cikla; vsaka vrstica pokaže stanje življenjskega cikla, število članov, stanje uvajanja in porabo v zadnjih 30 dneh. Pregled posamezne organizacije doda razlog za njeno stanje, njene člane, porabo v zadnjih 7 in 30 dneh s ključi z največjo porabo ter zadnjih 50 zapisov revizijskega dnevnika. Revizijski dnevnik je mogoče preiskovati tudi za vse organizacije hkrati, po organizaciji, uporabniku, dejanju in času.

Sprememba življenjskega cikla je en klic s stanjem, razlogom in neobvezno številko podpornega zahtevka (ticket).

Kaj jamči

  • Eno preverjanje pred vsemi končnimi točkami. Žeton se preveri, preden se izvede katera koli končna točka skupine, klic z žetonom brez admin:endpoints pa dobi problem 403 s kodo system_scope_not_permitted. Testi za middleware preverijo odgovor 403.
  • Sprememba življenjskega cikla ima razlog in revizijski zapis. Klic brez stanja ali razloga je zavrnjen s problemom 400 (missing_status ali missing_reason). Novo stanje in revizijski zapis ADMIN_LIFECYCLE_SET s stanjem, razlogom in številko zahtevka se shranita v enem klicu SaveChanges.
  • Začasna onemogočitev velja od naslednje zahteve. Večnajemniško delovanje življenjski cikel prebere ob vsaki zahtevi na končno točko za najemnika, ki neaktivnih organizacij ne dopušča, zato naslednji tak klic organizacije dobi 423, kar preverijo testi za middleware.
  • Strani ostanejo omejene. Stran ima privzeto 25 vrstic in največ 200, imenik pa podrobnosti za stran naloži s stalnim številom poizvedb, ne z eno poizvedbo na vrstico.

Kaj napiše produkt

Registracija in mesto preverjanja v verigi obdelave zahtev, ki jo v celoti prikazuje razdelek Večnajemniška zasnova:

builder.Services.AddMicroSaasAdminApi();            // the Admin API's options
// ... after app.UseAuthentication() and app.UseAuthorization():
app.UseMicroSaasTenancy();                          // the system-scope gate
app.MapMicroSaasAdminEndpoints<AppDbContext>();     // every route: "admin:endpoints"

Povezave

Skrbniški API dela z organizacijami, člani, življenjskim ciklom in revizijskim dnevnikom iz razdelka Uvajanje in organizacije, seštevki porabe pa prihajajo iz števcev modula Usage.

Admin.Api

Gradnik · paket npm

Gradnik za oblikovni sistem

Produkt jedro svojega uporabniškega vmesnika dobi iz enega paketa npm, svojo znamko zapiše v eno datoteko, njegovi lastni testi pa zavrnejo znamko, ki krši pogodbo gradnika.

Kako deluje

Gradnik je en paket npm, objavljen kot izvorna koda v TypeScriptu in CSS, brez koraka gradnje in brez lastnih odvisnosti: React, React Router in i18next so odvisnosti, ki jih namesti produkt (peer dependencies), pripete na točne različice. Vsebuje oblikovne žetone, komponente ds- z ovojnicami za React, katalog komponent, pravila linterja in pomagala za teste.

Slogovna datoteka gradnika enkrat in v tem vrstnem redu deklarira sedem kaskadnih plasti (cascade layers): ds.reset, ds.tokens, ds.brand, ds.base, ds.layout, ds.components in product. Produkt uvozi CSS gradnika, za njim pa svoj brand.css. Ta datoteka v @layer ds.brand nastavi dvanajst vlog znamke, jedro pa tem vlogam ne da nobene vrednosti. Lastna pravila produkta sodijo v zadnjo plast, product.

Konfiguraciji za ESLint in stylelint produkt sestavi s tovarniškima funkcijama gradnika, createEslintConfig in createStylelintConfig, njegovi testi pa pokličejo dve pomagali: assertBrand za datoteko znamke in assertKitLintRules za obe konfiguraciji.

Tudi ta stran je zgrajena na plasteh, žetonih in pogodbi znamke gradnika: paket pripne, CSS gradnika vključi pred svojim, njen brand.css pa v testih prestane assertBrand.

Kaj jamči

  • Znamko sestavlja dvanajst vlog in nič več. assertBrand vrže izjemo, če katera od vlog manjka ali datoteka znamke nastavi katero koli drugo lastnost --ds-*, in v sporočilu navede vsako manjkajočo ali odvečno lastnost. Test nastavi barvo okvirja fokusa, ki je vloga jedra, in assertBrand znamko zavrne.
  • Kontrast in barve stanj so izmerjeni. assertBrand preveri kontrast desetih parov barv po merilih WCAG: pri devetih zahteva vsaj 4,5 : 1, med njimi za barve povezave, znamke, dejanja in trenutnega elementa na beli podlagi ter za besedilo na barvi dejanja, pri znaku znamke na beli podlagi pa vsaj 3 : 1. Štiri interaktivne barve morajo biti od tonov za uspeh, opozorilo in nevarnost privzeto oddaljene vsaj ΔE2000 20, zato barva dejanja ne more delovati kot barva stanja.
  • Konfiguracije linterja v produktu so preizkušene. assertKitLintRules s produktovima konfiguracijama za ESLint in stylelint preveri trinajst odlomkov kode, ki jih gradnik prepoveduje, na primer className ali style na zaslonu, surov HTML, razred ds- ali barvo, zapisano neposredno v CSS produkta, in vrže izjemo, če kateri od njih ni zavrnjen kot napaka.
  • Dostopnost in videz komponent sta preverjena. V 55 testnih datotekah komponent axe-core preveri to, kar komponenta izriše. CI gradnika zajame vsak vnos kataloga z dvema testnima znamkama v treh širinah in vsak posnetek primerja z referenčnim posnetkom v repozitoriju, od katerega se ne sme razlikovati v nobenem pikslu; tudi manjkajoč ali odvečen posnetek povzroči neuspeh.

Kaj napiše produkt

Svojo znamko, skrajšano in z nevtralno predpono:

@layer ds.brand {
  :root {
    --acme-navy-900: #111a2a;
    --acme-navy-800: #19283f;
    --acme-orange-500: #f59e0b;
    --ds-color-brand: var(--acme-navy-800);
    --ds-color-action: var(--acme-navy-800);
    --ds-color-action-hover: var(--acme-navy-900);
    /* ... eight more of the twelve roles ... */
    --ds-border-brand: var(--acme-orange-500);  /* decorative only, never text */
  }
}

Test, ki preveri, ali znamka ustreza pogodbi:

import { fileURLToPath } from "node:url";
import { assertBrand } from "@<scope>/design-system/testing";
import { it } from "vitest";

it("brand.css honours the design-system brand contract", () => {
  assertBrand(fileURLToPath(new URL("./brand.css", import.meta.url)));
});

Povezave

Opravila za uporabniški vmesnik v gradniku za CI/CD paket namestijo z izklopljenimi namestitvenimi skripti. O lastni različici gradnika in o zastavicah v njegovem dnevniku sprememb, [contract], [visual] in [peer], govori razdelek Izdaje in različice.

brand.cssassertBrandassertKitLintRules

Gradnik · GitLab CI/CD

Gradnik za CI/CD

Produkt svoj cevovod sestavi iz komponent gradnika v eni pripeti različici: testi s pregledom ranljivosti v odvisnostih, pregled skrivnosti, gradnja slik, namestitev, ki naredi varnostno kopijo baze in se konča s preverjanjem zdravja, ter obnovitveni test.

Kako deluje

Gradnik sestavlja sedem komponent CI/CD za GitLab in slika vsebnika ops s skripti za namestitev: dotnet-test zgradi rešitev .NET, jo testira z bazo PostgreSQL in v paketih NuGet poišče znane ranljivosti, secret-scan z orodjem gitleaks pregleda potrditve, ki jih prinese cevovod, frontend-gate pa namesti odvisnosti brez namestitvenih skriptov in požene npm audit; ostale štiri so frontend-test, image-build, deploy in restore-test.

Namestitev teče na izvajalniku CI, ki do strežnika dostopa prek SSH. Datoteka .env, konfiguracija strežnika in geslo za register potujejo po standardnem vhodu, nikoli kot argumenti ukaza, ključ strežnika pa je pripet in ga namestitev nikoli ne pridobi s skeniranjem. Na strežniku skript gradnika naredi varnostno kopijo, prenese slike, zažene vsebnike, počaka, da navedeni vsebniki sporočijo, da so zdravi, in požene kljuko produkta. Obnovitveni test teče po urniku ali ročno, nikoli med namestitvijo v isto okolje. Lastni cevovod gradnika komponento za namestitev preizkusi od začetka do konca na testnem strežniku.

Kaj jamči

  • Preverjeno, preden se namestitev dotakne strežnika. Manjkajoča spremenljivka, naslov za preverjanje zdravja z neizpolnjenim mestom za vrednost, manjkajoča datoteka Compose ali manjkajoče poverilnice za register ustavijo namestitev pred prvo povezavo SSH, manjkajoče spremenljivke produkta pa so navedene v enem sporočilu. Namestitev brez naslova za preverjanje zdravja je zavrnjena.
  • Preverjanje zdravja od zunaj. Izvajalnik vsak naslov za preverjanje zdravja odpre od zunaj, prek DNS in robnega strežnika, dokler ne dobi odgovora s statusom pod 400, privzeto največ 24-krat. Preveri vse naslove, tudi ko preverjanje katerega od njih že ni uspelo, in vsak neuspeh opravilo konča z napako. Naslovi, ki jih produkt navede kot zaprte, morajo vrniti 401, 403 ali 404.
  • Konsistentna varnostna kopija pred prenosom slik. Izvoz delujoče baze in števila vrstic izhajajo iz istega posnetka stanja (repeatable read), medtem ko stara različica produkta še zapisuje vanjo. Vsaka datoteka dobi kontrolno vsoto SHA-256, neuspela varnostna kopija pa namestitev ustavi. Test na pravi bazi PostgreSQL med varnostnim kopiranjem ves čas zapisuje vrstice, nato pa kopijo obnovi z enakim številom vrstic.
  • Obnovitveni test, ki se delujočega strežnika ne dotakne. Najprej preveri kontrolne vsote, nato kopijo obnovi v začasni vsebnik brez omrežja, zagnan iz slike delujoče baze, in zahteva, da se vsak izvoz obnovi in da se obnovljena števila vrstic ujemajo s tistimi, ki jih je zabeležila varnostna kopija. Testi preverijo, da delujoči strežnik ostane nedotaknjen in da je poškodovan arhiv zavrnjen, preden se kar koli obnovi.

Kaj napiše produkt

Namestitev v eno okolje, skrajšano:

include:
  - component: $CI_SERVER_FQDN/…/deploy@delivery-vX.Y.Z
    inputs:
      environment: dev
      product: <product>
      environment_url: https://$DEV_APP_HOST
      required_vars: APP_HOST API_HOST AUTH_HOST POSTGRES_PASSWORD
      health_urls: https://{API_HOST}/health https://{APP_HOST}/
      theme_check: "<realm> <realm>-spa <theme>"
      backup_db_user: <product>
      backup_db_name: <product>
      needs: ["build:api", "build:keycloak"]

Obnovitveni test, v istem seznamu:

  - component: $CI_SERVER_FQDN/…/restore-test@delivery-vX.Y.Z
    inputs: { environment: dev, product: <product> }

Povezave

Namestitev privzeto počaka na Keycloak produkta, zgrajen na gradniku za identiteto, theme_check pa na strani za prijavo poišče njegovo temo. Opravila za uporabniški vmesnik namestijo gradnik za oblikovni sistem z lastnim žetonom opravila. O različicah gradnika in zastavicah v njegovem dnevniku sprememb govori razdelek Izdaje in različice.

deployrestore-testsecret-scan

Paketi .NET

Izdaje in različice

Vsaka izdaja v dnevniku sprememb pove, kaj mora produkt narediti, da jo prevzame, produkt pa jo prevzame tako, da premakne eno pripeto različico.

Kako deluje

Devetnajst paketov .NET ima eno skupno različico, njihovo izdajo pa sproži oznaka vX.Y.Z v repozitoriju toolkita. Produkt različico pripne enkrat, kot lastnost v svoji datoteki Directory.Build.props, vsak sklic na paket pa prebere to lastnost. Paketa Abstractions in Tenancy.Abstractions, ki vsebujeta pogodbe, skupne modulom, nista odvisna od nobenega drugega paketa.

Vsak gradnik ima svojo različico in se izda z veje main. Izdaja je zahteva za združitev, ki zgornjemu razdelku dnevnika sprememb gradnika doda datum; po združitvi opravilo za izdajo na tej potrditvi požene teste, gradnik objavi in potrditvi doda oznako.

Vsak dnevnik sprememb z zastavicami pove, kaj mora narediti produkt: pri paketih vnos dobi [migration], ko DbContext produkta potrebuje migracijo, [contract], ko se je spremenil odgovor ali javni podpis, in [behaviour], ko se je pri isti pogodbi spremenil izid. Gradniki dodajo svoje zastavice, na primer [keycloak], [visual], [peer] in [pipeline].

Izdajo s produkti povezujeta dva skripta tovarne. Prvi s kandidatom za izdajo toolkita, še preden ta dobi oznako, zgradi vsak aktiven produkt, ki uporablja pakete. Po izdaji paketov, gradnika za identiteto ali gradnika za CI/CD pa drugi odpre zahtevo za združitev v vsakem aktivnem produktu, ki ima pripeto starejšo različico.

Kaj jamči

  • Ena različica za devetnajst paketov. Cevovod ob oznaki vse pakete zapakira v različici iz oznake in se konča z napako, če jih ne nastane natanko devetnajst. Testi tečejo v zgodnejši fazi, zato cevovod ob neuspelih testih ne objavi ničesar.
  • Nobene izdaje brez razdelka v dnevniku sprememb. Cevovod paketov zavrne različico, ki v dnevniku sprememb nima razdelka ali jo ta še označuje kot neizdano. Opravilo za izdajo gradnika različico prebere iz zgornjega naslova dnevnika, ki mora biti ## X.Y.Z — YYYY-MM-DD ali, kadar se nič ne izda, ## X.Y.Z — unreleased; vse drugo opravilo ustavi.
  • Oznaka gradnika sledi preverjeni objavi. Opravilo za izdajo nikoli ne prepiše različice, ki jo register že ima, neuspela poizvedba pa ga ustavi. Po objavi preveri sliko ali paket, kot ga vrača register, in šele nato doda oznako potrditvi, iz katere je objava nastala.
  • Nadgradnja pride skupaj z zapisi iz dnevnika. Zahteva za združitev, ki jo odpre skript tovarne, spremeni le pripeto različico, v opisu pa nosi vse razdelke dnevnika sprememb med različico produkta in novo; združljivost preveri cevovod te zahteve za združitev v produktu samem.

Kaj napiše produkt

Pripeta različica, enkrat za ves produkt, in sklic na paket, ki jo prebere:

<!-- Directory.Build.props: one version for every toolkit package -->
<PropertyGroup>
  <MicroSaasToolkitVersion>X.Y.Z</MicroSaasToolkitVersion>
</PropertyGroup>
<!-- A project file: each toolkit package reads the same property -->
<PackageReference Include="MicroSaas.Toolkit.Tenancy.Runtime"
                  Version="$(MicroSaasToolkitVersion)" />

Povezave

Tudi vsak gradnik je pripet na točno različico: slika gradnika za identiteto z različico in povzetkom (digest) v FROM, gradnik za oblikovni sistem z različico v package.json in package-lock.json, komponente gradnika za CI/CD pa z @delivery-vX.Y.Z v vsaki vključitvi.

MicroSaas.Toolkit.AbstractionsCHANGELOG.md

Kako ta stran ostaja točna

Vsak primer v C# je prepisan iz majhnega projekta, ki ga prevedemo z različico toolkita, opisano na tej strani, testi spletnega mesta pa javijo napako, če kakšen blok na strani ni dobesedno enak delu tega projekta. Drugi primeri so vzeti iz dejanskih datotek produktov in repozitorija toolkita, po potrebi skrajšani, ali pa zapisani ob zagonu kode te različice. Stran ne sledi vsaki izdaji: na mesečnem pregledu tovarne jo primerjamo z izdajami od zadnjega pregleda in posodobimo, kar so te spremenile.

Stanje: oktober 2026