Function access control check happens in later stage of a c++ function call.
The order in high level would be like name lookup, template argument deduction(if any), overload resolution, then access control(public/protect/private) check.
But in your snippet, you were using a pointer to base class and function f() in base class is indeed public, that's as far as compiler can see at compiler time, so compiler will certain let your snippet pass.
A *ptr = new B;
ptr->f();
But all those above are happens at compile time so they are really static. While virtual function call often powered by vtable & vpointer are dynamic stuff which happens at runtime, so virtual function call is orthogonal to access control(virtual function call happens after access control),that's why the call to f() actually ended B::f() regardless is access control is private.
But if you try to use
B* ptr = new B;
ptr->f()
This will not pass despite the vpointer & vtable, compiler will not allow it to compile at compile time.
But if you try:
B* ptr = new B;
((static_cast<A*>(ptr))->f();
This would work just fine.